Skip to content

Multi-Company

Fullfinity supports multiple companies sharing a single database. One install of one database can hold several legal entities, with users belonging to one or more of them, records scoped per company, and configuration values that differ per company.

This is not the same as multi-tenancy. Multi-tenancy gives each tenant a separate database; multi-company puts several companies inside one database. See Multi-Tenancy for the separate-database model.

A company is a record like any other. The core Company model holds the legal-entity details — name, address, currency, tax id, branding:

class Company(Model):
_verbose_name = "Company"
_track = True
name = Char(max_length=255, index=True, required=True)
currency = ManyToOne("Currency", related_name="companies", on_delete="RESTRICT")
country = ManyToOne("Country", related_name="companies", on_delete="SET NULL")
image = File(description="Logo")
tax_number = Char(max_length=50, description="Tax ID")
# ... address, contact, and social fields

Users, default company, and allowed companies

Section titled “Users, default company, and allowed companies”

Every user belongs to a company and may have access to several. Two fields on User drive this:

class User(Model):
default_company = ManyToOne(
"Company",
related_name="users_with_default_company",
required=True,
on_delete="CASCADE",
default=lambda self: self.get_default_company(),
description="Default Company",
)
allowed_companies = ManyToMany(
"Company",
related_name="users",
through="FkUserCompany",
description="Allowed Companies",
default=lambda self: self._default_allowed_company_commands(),
)
  • default_company (required) is the user’s home company — the one selected by default.
  • allowed_companies (M2M) is the full set of companies the user may work with. A new user starts allowed into the company they were created under, not every company — adding a company never silently widens anyone’s data scope.
  • default_company is always among allowed_companies — writing either side re-asserts that, so a user can never end up defaulted into a company they may not work in.
  • Creating a company allows its creator into it. A company nobody is allowed into is unreachable: the company switcher offers a user’s allowed_companies, so without this the second company an admin adds is invisible even to them. The grant is the creator’s alone; everybody else is let in explicitly, on the user record.

Declare the field. That is the opt-in, and it is the whole of it:

class Order(Model):
company = ManyToOne("Company", related_name="orders", on_delete="CASCADE")

From there the framework scopes reads to the companies the user is working with, and refuses writes that reference another company’s records. You do not enable either.

Blank means shared. A record with no company is available to every company — that is how master data works: customers, products, price lists are normally the group’s, not one entity’s. Restricting one to a single company is still possible; it just has to be deliberate, and once done it is enforced.

# Master data — usually leave company unset, i.e. shared:
company = ManyToOne("Company", related_name="products", on_delete="CASCADE",
hint="Leave blank to share this product with every company")
# A document — belongs to the company that raised it:
company = ManyToOne("Company", related_name="orders", on_delete="CASCADE",
default=lambda self: get_user_company(self))
# A line — takes its company from its parent, never from whoever is logged in:
company = ManyToOne("Company", related_name="order_lines", on_delete="CASCADE",
related_field="order__company")

That third form matters more than it looks. A child that defaults to the creating user’s company can disagree with its own parent — a user whose default company is A, working in company B’s warehouse, producing a transfer belonging to B whose moves belong to A. Derive from the parent and the two cannot drift apart.

Acting for a specific company — as_company(X)

Section titled “Acting for a specific company — as_company(X)”

The one verb. Use it when code works on a company it was handed rather than one a user picked — a job looping every company, an install hook seeding per-company records, a method acting for a document’s own company:

from fullfinity.engine.base import as_company
for co in await Company.filter().all():
with as_company(co.id):
... # reads resolve here, new records belong here, settings resolve here

Inside the block one company is in force and everything follows from it: what is visible, what new records belong to, and which company’s company_scoped settings apply. It nests, restores the previous state on exit including on exception, and a falsy id is a no-op so an optional company needs no branch.

It is not a bypass. One company is exchanged for another; nothing becomes visible that a normal read of that company would not return.

Application code rarely needs these, but they are what the above is built on:

env = env_ctx.get()
env.company_id # int | None — the company being acted for
env.active_company_ids # list[int] — the companies whose data is visible
env.allowed_companies # list[dict] — every company the user may access

active_company_ids is the company switcher’s checkbox selection: which companies’ data the user has enabled in their view. “Active” means ticked, not current — its order is whatever the checkboxes were, so its first element means nothing in particular. Never read active_company_ids[0] as “the current company.”

env.company_id is the company being acted for: inside as_company(X) it is X; otherwise the user’s default company (User.default_company, set by the switcher’s per-company default toggle). This is what the base Configuration model reads and writes every company_scoped setting against, and what a new record’s company default should use.

There is no second answer. env.company_id is the company for every purpose: resolving a record with a real company FK (an account, a journal, a warehouse), stamping a new document, and keying a company_scoped field’s per-company value. company_scoped storage resolves through env.company_scope_key, which is env.company_id — so the JSONB write, the read that extracts it, and the SQL the query builder emits for a filter, a sort, a group-by, an aggregate or a join all key on the same deterministic company. You will not normally name company_scope_key yourself; the field mechanism does it for you.

Reads are auto-scoped to the selected companies

Section titled “Reads are auto-scoped to the selected companies”

Any query on a model with a stored company field is automatically filtered to the selected set — a record is returned only if its company is in active_company_ids or it has no company (global/shared). You don’t write this filter; it’s applied to every read. So a user who hasn’t selected company B never sees B’s records in lists, searches, or pickers.

An empty selection is not “no filter”. A user whose selection is empty is allowed no companies, so their company-scoped reads return nothing — it fails closed, and logs a warning naming the user. Background work (a scheduled job, an automation, a CLI install) has no selection to speak of and reads across all companies.

You do not have to do anything about this. Cron methods and automation actions already run inside the framework’s own background marker, so your code behaves correctly wherever it runs. It is mentioned only so an empty result in a background job is recognizable rather than mysterious.

Out-of-company relations render as a restricted marker, not a blank. If a record you can see holds a relation to a record in a company you haven’t selected — e.g. a sales order on company A whose customer belongs to company B — you get a clear 🔒 <Company> marker instead of a silent blank, and it can’t be opened. This is automatic: your model’s get_display_name never has to know about it and must not special-case it (it is never even called for such a record, so the hidden record’s name cannot leak).

How that marker is delivered depends on whether the relation is a single reference or a collection — because a collection has no single label slot to put a lock in:

  • ManyToOne / OneToOne — the value arrives as {id, display_name: "🔒 <Company>", name: "🔒 <Company>", restricted: true, restricted_company: "<Company>"} and nothing else — the record’s real name and data never cross the boundary. The label satisfies the {id, display_name} convention every consumer speaks (relation widgets, but equally server-rendered reports, portal pages and exports); the restricted flag rides alongside so a widget can act on the state — refusing to open the record — rather than pattern-matching the string. Naming the company is deliberate: which company is actionable, and a single reference discloses no volume.
  • OneToMany / ManyToMany — out-of-company members are not returned as members at all. Instead the field reports the owning companies on the record: _restricted_relations: {"lines": ["<Company>", ...]}, which the UI renders as ”🔒 Some records belonging to … are not shown”. Only which companies are disclosed — never how many, and never placeholder rows. A count would expose another company’s business volume, and a synthesised row would be rendered through whatever column widgets the view declares (a Monetary column would show 0.00), reading as a real, zero-valued record.

This keeps “you can’t see this” distinguishable from “there are none” — an empty collection always means genuinely empty. The lock label is produced only when a record is serialized; the stored column is untouched, so ordering and filtering still run against the real data.

Records hidden by a record rule (rather than by company scope) are unaffected: they keep their real name, as before.

Reads are filtered, but an INSERT carries no WHERE clause — so nothing stopped a record being written with a reference to another company’s record. That gap is closed at the write: a company-scoped record may reference only records of its own company, or ones belonging to no company.

# Company B's order, Company A's product:
await SaleOrderLine.create(order=order_b.id, product_variant=variant_a.id, ...)
# ValidationError: "Product" points at a record belonging to Company A, but this
# Sale Order Line belongs to Company B. A record can only reference records of its
# own company, or ones shared across all companies.

Both sides resolve their declared company, whether that is a stored column or a related_field path of any depth. That is what lets it cover lines: an order line has no company column of its own — it derives from order__company — so it is not company-filtered at all, and this check is the only thing that sees it.

Shared master data: companies instead of company

Section titled “Shared master data: companies instead of company”

Some master data is genuinely one record used by several companies, each presenting it differently. A chart-of-accounts account is the case: every localization pack re-declares the same account under the same identifier, so there is one row, with each company’s national number and label held per-company alongside it. Copying the chart per company would make group reporting reconcile duplicates instead of reading one account.

Such a model declares companies — a ManyToMany("Company") — instead of company:

class Account(Model):
companies = ManyToMany("Company", related_name="accounts", through="FkAccountCompany")

The two declarations answer different questions, and the check reads both:

DeclarationMeaningAs a reference target
company (M2O)owned by exactly one companyallowed from that company only
companies (M2M)usable by the companies linked to itallowed from any of them; allowed from everywhere while the set is empty
neitherunscoped reference data (Currency)allowed from anywhere

That last row of the middle case is the important one: shipped master data starts with no links and is therefore available to everyone, which is what lets a fresh install work before any company has adopted anything. Linking a record to one company withdraws it from the others — so if a shared record is expected to stay available, every company must be linked to it, not just the ones that customised it.

Two consequences worth knowing:

  • Opt-in is by field name, exactly as it is for company. A ManyToMany("Company") under any other name is not an ownership declaration — User.allowed_companies means “may act for”, and treating it as ownership would refuse writes referencing a user whose grant list happened not to include the writing company.
  • Only the target side is set-valued. A shared record is allowed to reference anything, so it is never checked as the referrer; the referrer is always a single-company record.

Read filtering is not automatic for a shared model. The company column drives the query filter described above; companies does not, because a shared record has no owning company to filter on — who may see it is a permissions question, so express it as a record rule and use company_visibility_q() to build the predicate rather than retyping it:

# "linked to this company, or linked to none" — one predicate, one place
accounts = await Account.filter(Account.company_visibility_q(company_id)).all()

Keeping visibility in a record rule also keeps it bypassable by an administrator doing deliberate cross-company work (consolidation, group-wide reports), which company isolation itself is not.

There is no opt-out, and none has been needed. Every case that looked like it required one turned out to be a record that should have been shared: master data stamped with its creator’s company, or an intercompany partner contact that has to be usable from every member’s books. If a reference genuinely spans companies, the record it points at is company-less.

It validates writes, not existing rows. Moving a record to another company leaves every record pointing at it referencing across companies, without any write to them — nothing re-checks referrers. That is legitimate, and it is why relations to records in other companies still render as a restricted marker rather than being assumed impossible.

Installing, updating or uninstalling a module is a database-wide administrative act, not a person browsing. It therefore runs with no company selection at all, regardless of what the caller had selected — so an operation triggered from the UI behaves exactly like the same operation from the command line.

This matters for install hooks. The common shape is a per-company guard:

for company in await Company.filter().all():
if not await Warehouse.filter(company__id=company.id).first():
await Warehouse.create(name=f"{company.name} WH", company=company)

That is correct as written, because a module operation carries no selection and the read is unfiltered. Had it inherited the admin’s selection, the guard would have found nothing for every company they had not ticked — while the create, being an INSERT, is filtered by nothing and would have duplicated the record.

The same applies to data migrations, which run as part of an upgrade.

Working on one company inside a background job

Section titled “Working on one company inside a background job”

A scheduled job sees every company’s data (it has no selection), and the company it should act for is the one carried by the record it is processing:

for entry in await RecurringEntry.filter(next_date__lte=today).all(): # all companies
await entry.generate() # entry.company

Use entry.company_id for anything you look up alongside it — the journal, template or sequence — rather than an unqualified .first(), which would return an arbitrary company’s record.

One case needs more: company_scoped settings resolve against the company being acted for, and a job is acting for none, so they return the global value rather than a specific company’s. When a job needs per-company settings, act as each company in turn:

for co in await Company.filter().all():
with as_company(co.id):
... # settings, sequences and account roles resolve for THIS company

There is deliberately no “see every company” switch. elevate() does not do it — it lifts permissions, not isolation — and no other primitive is exposed for it. If you find yourself wanting one, one of these is almost certainly what you actually need:

Per-company work — loop and act as each. The normal answer, and the safe one: each iteration reads and writes as that company, so per-company settings, sequences and defaults all resolve correctly.

for co in await Company.filter().all():
with as_company(co.id):
...

A specific set of companies — set the selection. For code handed its companies from outside (an inbound webhook naming its company, an integration syncing a fixed set), assign the selection and restore it afterwards:

env = env_ctx.get()
previous = env.active_company_ids
env.active_company_ids = [txn.company_id]
try:
...
finally:
env.active_company_ids = previous

A genuine cross-company aggregate — raw SQL. Consolidated reporting sums many companies’ ledgers in one pass, which is aggregation the ORM does not express well anyway. Raw SQL bypasses isolation along with everything else, so every predicate is yours to write — including the company one. See Raw SQL Queries.

What you should not do is reach for elevate() expecting it to widen a read. It will not, and the read will silently return nothing for the companies you are not scoped to.

Gating company UI: automatic — the core_multi_company group

Section titled “Gating company UI: automatic — the core_multi_company group”

On a single-company install, company fields are noise. They are gated behind the core_multi_company group, defined in modules/core/security/security.yaml:

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

You do not gate the company field yourself. Any widget bound to a relation to the Company model is auto-gated to core_multi_company during view composition — on every Form, List and Search, with no per-view config:

# Just declare the field — no `groups:` needed. The framework adds the gate.
- field: company
properties: {widget: DataCombo, label: Company}

The input appears once the database holds more than one company and the reader works across companies — they are already allowed into more than one, or they hold core_multi_company (admins are implied into it). Both halves matter, and the first one asks about the database, not the reader: with a single company there is nothing to pick, so the input stays hidden from everyone, admins included; and as soon as a second company exists the pickers must appear for an admin who is still allowed into only one — otherwise User.allowed_companies, itself a company widget, hides the only field that could let anybody into the new company.

This is widget-level only: the company value is still serialized, so grouping, domains, record rules and turning multi-company on later all keep working — nothing about the value is access-restricted. (Contrast field-level groups, which omits the value — the wrong tool for company, which must always flow.) The rule applies to ManyToOne, OneToOne and ManyToMany relations to Company alike.

Opt a specific widget out with company_gate: false in its properties — rare, since in a single-company database there is nothing worth showing.

Sometimes the same record needs a different value per company — a product category whose income account differs by entity, or a configuration toggle that differs per company. That’s the company_scoped field option:

class ProductCategory(Model):
name = Char(max_length=255)
income_account = ManyToOne(
"Account",
description="Income Account",
company_scoped=True, # value stored per company
)

A company_scoped field does not live in the model’s own column. Its per-company values are stored against the record, keyed by company, and read back for the active company with a fallback to the global default. The storage key format is:

Model.record_id.field_name # regular models, e.g. ProductCategory.5.income_account
field_name # transient models (wizards/configuration), e.g. crm_auto_assign

(get_company_scoped_key in the engine builds these.) A company value falls back to a global default when no company-specific value is set.

Because the value is stored as a per-company blob rather than a plain column, you cannot query against it the way you would a normal field:

  • Cannot filter on a company-scoped field: filter(income_account=x) won’t work.
  • Cannot sort on a company-scoped field.
  • No database-level uniqueness constraints.

Use company-scoped fields for configuration and properties read by id, not for fields you need to search or order by. See Field Types for the full behavior of company_scoped.

CompanyConfig: the per-company key-value store

Section titled “CompanyConfig: the per-company key-value store”

Underpinning company-scoped configuration is the CompanyConfig model — a key-value store where company = NULL is the global default and company = <id> is a company-specific override. Lookups try the company-specific value first, then fall back to global:

CompanyConfig = get_model("CompanyConfig")
# Read for a company, with global fallback if no company-specific value exists
value = await CompanyConfig.get_value(
"exchange_gain_account",
company_id=env.default_company_id,
default=None,
)
# Write a company-specific value (omit company_id to set the global default)
await CompanyConfig.set_value(
"exchange_gain_account",
account_id,
company_id=env.default_company_id,
)

get_value resolves in order: company-specific value → global value → the supplied default. set_value writes the company-specific row when company_id is given, otherwise the global row. Values are JSON-serialized and cached per request.

To scope records to companies, use a record rule that allows the active companies plus the company-less (global) records. The record-rule context exposes cids (the active company ids):

- data_type: RecordRule
identifier: sale_order_all_company_orders_rule
name: 'Sale Order: All Company Orders'
model: SaleOrder
groups:
- sales_manager_group
rule: Q(company__id__in=cids)
read_perm: true
write_perm: true
create_perm: true
delete_perm: true

When a model has both company-specific and shared records, the common pattern is to allow both — the company-less rows are visible to everyone, the company rows only to that company:

company_filter = Q(company__isnull=True)
if self.company:
company_filter = company_filter | Q(company=self.company)

This Q(company__isnull=True) | Q(company=...) shape keeps shared/global records visible across all companies while restricting company-owned records to their company.

Multi-CompanyMulti-Tenancy
BoundaryMultiple companies in one databaseOne separate database per tenant
Data sharingCompanies can share records (global rows)No cross-tenant access whatsoever
UsersA user can span several companiesA user belongs to one tenant DB
Scopingcompany field + record rules + company_scopedDatabase routing by host/subdomain

Reach for multi-company when several legal entities operate together and need to share master data; reach for multi-tenancy when tenants must be fully isolated. See Multi-Tenancy.