Licensing & Enterprise
Some Fullfinity modules are enterprise (licensed) features. They live under
fullfinity/enterprise/ and are gated at runtime: without a valid license, the engine
blocks reads and writes of enterprise-owned data and omits enterprise field values from
serialized output. Everything else — the engine, the ORM, and all free modules under
fullfinity/modules/ — runs unlicensed.
This page documents how the gate is defined, how it is enforced, and how to add a new enterprise module correctly.
The source of truth: _ENTERPRISE_MODULES
Section titled “The source of truth: _ENTERPRISE_MODULES”The gate is a single hardcoded frozenset of module identifiers in
fullfinity/engine/licensing/__init__.py:
_ENTERPRISE_MODULES: Set[str] = frozenset( [ "accounting", "accounting_asset", "approvals", "helpdesk_sla", "inventory_planning", "quality", # ... ])
def is_enterprise_module(module_identifier: str) -> bool: """True if a module identifier is an enterprise (licensed) module.""" return module_identifier in _ENTERPRISE_MODULESIt is hardcoded on purpose, for two reasons:
- It compiles into the engine executable. A self-hosted customer ships the compiled engine and cannot edit the list out of the binary.
- It keys on the module identifier, not on where the files sit. The check is
module_identifier in _ENTERPRISE_MODULES, where the identifier is intrinsic to the module. So movingfullfinity/enterprise/accountingintofullfinity/modules/does not make it free (the idaccountingis still gated), and renaming the module to escape the list breaks its own tables and references.
Auto-discovering the gated set from the enterprise/ directory was considered and
rejected: directory contents depend on the filesystem, which the customer controls, so
a simple mv would bypass the gate. The directory layout is a developer convention; the
identifier list is the security boundary.
Keeping the list complete: the CI test
Section titled “Keeping the list complete: the CI test”Because the list is the boundary, the one real risk is a developer footgun: adding an
enterprise module under fullfinity/enterprise/ and forgetting to add its identifier to
_ENTERPRISE_MODULES, shipping it ungated. A test catches exactly that:
from fullfinity.engine.testing import TestCasefrom fullfinity.engine.licensing import _ENTERPRISE_MODULES, discover_enterprise_dir_modules
class TestEnterpriseGateCompleteness(TestCase): async def test_every_enterprise_dir_module_is_gated(self): on_disk = discover_enterprise_dir_modules() missing = sorted(on_disk - set(_ENTERPRISE_MODULES)) self.assertEqual(missing, [], ...)discover_enterprise_dir_modules() scans fullfinity/enterprise/ and returns the
identifiers physically present there. It is used only by this test, never for gating.
If any module under enterprise/ is missing from the hardcoded list, the test fails in CI
before the module can ship ungated. Keep the runtime gate hardcoded; let the test enforce
that it covers the directory.
Adding an enterprise module — two edits in sync
Section titled “Adding an enterprise module — two edits in sync”Adding an enterprise module is always two edits that must stay in sync:
- Put the module under
fullfinity/enterprise/<module>/(same structure as a free module). - Add its identifier to
_ENTERPRISE_MODULESinfullfinity/engine/licensing/__init__.py(keep the list grouped/sorted).
A free/base module goes in fullfinity/modules/ and is never listed. If you do step 1
without step 2, the completeness test fails — which is the point.
The runtime gate (data access)
Section titled “The runtime gate (data access)”Every CRUD path checks the owning module before touching data. The check lives on the model class:
async def _check_enterprise_access(cls): from fullfinity.engine.licensing import is_enterprise_module, _enterprise_licensed
module = getattr(cls, "_module", None) if not module or not is_enterprise_module(module): return # Not an enterprise model, allow access
env = env_ctx.get() if not await _enterprise_licensed(env): from fullfinity.engine.exceptions import LicenseError raise LicenseError( "This feature requires an active Enterprise license. Please contact your administrator." )_check_enterprise_access() is invoked from create, save, update, delete, and the
read path, so an unlicensed install cannot create, modify, delete, or query records of a
model owned by an enterprise module.
LicenseError is a specialized AccessError (so existing except AccessError handlers
still catch it) that carries a stable code and returns HTTP 403:
ENTERPRISE_LICENSE_REQUIRED = "ENTERPRISE_LICENSE_REQUIRED"
class LicenseError(AccessError): code = ENTERPRISE_LICENSE_REQUIREDThe response body carries code: ENTERPRISE_LICENSE_REQUIRED so the frontend can tell it
apart from an ordinary 403.
Serialization: field values are omitted, never dropped
Section titled “Serialization: field values are omitted, never dropped”An enterprise module can also add a field to a free host model (for example, a rating
score on the free Ticket). The data-access gate above blocks whole enterprise models;
field-level gating happens in the serializer.
When the license is invalid, serialize() omits the values of fields owned by an
enterprise module — output only:
# Enterprise field gating: omit the value of a field owned by an enterprise module# when unlicensed (output only — the column/data are untouched).if not enterprise_licensed and _is_enterprise_module is not None: _owner = getattr(type(instance), "_field_modules", {}).get(field_name) or getattr( type(instance), "_module", None ) if _owner and _is_enterprise_module(_owner): continueField provenance comes from _field_modules, computed during per-DB class composition: a
field’s owner is the base-most class that declares it, so a free base field that an
enterprise extension merely overrides stays free.
This is output-only and fully reversible:
- The database column and its data are never dropped — there is no data loss.
- Re-activating the license immediately restores the values in serialized output.
- The data-access gate remains the real boundary for enterprise-owned models; field gating covers enterprise fields living on a free host model.
Composition: menus and inherited-view sections
Section titled “Composition: menus and inherited-view sections”Enterprise content is also excluded when the per-DB UI is composed for an unlicensed install:
- Menus belonging to enterprise modules are excluded.
- Inherited-view sections contributed by an enterprise module (fields/tabs added to a free view) are excluded from the composed view.
So an unlicensed user does not see enterprise menu entries or enterprise additions to shared forms — the gate is consistent across data, serialization, and UI.
Frontend: the upgrade dialog
Section titled “Frontend: the upgrade dialog”The React app’s axios response interceptor keys on the error code. A 403 whose body
carries code: ENTERPRISE_LICENSE_REQUIRED opens the shared upgrade dialog instead of
a plain error toast:
// Enterprise license gate: a 403 with code ENTERPRISE_LICENSE_REQUIRED opens the shared// upgrade dialog.if (error.response?.status === 403 && errorData?.code === "ENTERPRISE_LICENSE_REQUIRED") { // open UpgradeDialog}This is why LicenseError carries a distinct code: it lets the client distinguish “you
need a license” from “you lack permission”.
Activation
Section titled “Activation”There are two ways to license an install, both based on Ed25519-signed tokens (the public verify key is compiled into the engine). A token is a signed payload, not a boolean, so it cannot be forged or self-minted without the vendor’s private key.
Per-database token
Section titled “Per-database token”Activate a single database by posting its signed token to /api/config/activate:
@route("/api/config/activate", methods=["POST"], auth="internal")async def activate_config(self, request: Request): """Store a configuration token (license key) in params.""" body = await request.json() token = body.get("_configToken") # ... validate against the license server, then: await Params.create(name="_sys_config_token", value=token)The per-DB token is bound to the install’s node reference (a self-healing system
Params row) and a derived config seed, so it is valid only for that database.
Global key (SaaS)
Section titled “Global key (SaaS)”A single vendor-signed global token in config.yaml as LICENSE_KEY licenses every
database on the install — used for our SaaS and for issuing dev licenses. Put it in the
gitignored config.local.yaml:
LICENSE_KEY: "<base64_payload>.<base64_signature>"A global token carries a global claim and is not bound to a node ref or config seed,
so one key covers all databases. It is still an Ed25519-signed token — knowing that a
LICENSE_KEY option exists is useless without the vendor’s private signing key.
Grace period
Section titled “Grace period”When a license expires, the engine allows a 7-day grace period before access is cut off:
_GP = 7 * 24 * 60 * 60 # 7 daysDuring grace, the configuration state still reports ready (_cacheReady = True) so
enterprise data remains accessible; past the grace window it reports not-ready and the gate
engages. This applies to both the per-DB token and the global key.
No config or environment bypass
Section titled “No config or environment bypass”There is deliberately no config flag and no environment variable that disables licensing. Any such passive switch would be a backdoor for self-hosted installs (the customer controls config/env and could grep it out of source).
The only bypass is an in-process flag flipped solely by the test command:
_TEST_LICENSE_BYPASS = False
def set_test_license_bypass(active: bool = True) -> None: """Enable the in-process license bypass — called by the test runner ONLY.""" global _TEST_LICENSE_BYPASS _TEST_LICENSE_BYPASS = activeThe test command runs tests and never serves; at serve time nothing sets this flag, so
it cannot be turned on without modifying source. Local manual development of enterprise
features uses a real (free) dev license issued by the license server for the dev node — not
a bypass.
The most security-critical site (the data-access gate) calls _enterprise_licensed()
rather than the public _validate_config(). It computes its decision from the genuine
state function captured at import and trips to “unlicensed” if any public gate symbol has
been rebound — so the documented one-line monkeypatch does not re-enable enterprise data
access.