Department multitenancy
Enforce tenant roots and department subtrees consistently across forms, references and business data.
Department multitenancy
FormPlatform treats the department boundary as the outer security boundary and form ACL as the inner permission boundary. ACL grants never expand the authenticated user's department subtree. The built-in `Administrator` role is the only default bypass for department row filters.
Data structures and ownership
- `app_departments` stores the hierarchy through `parent_id`.
- `app_users.department_id` assigns a system user to one department. An Administrator may remain unassigned; an ordinary user without an active department receives an empty business-data scope.
- `form_definitions.department_id` owns a form definition. A null department publishes a shared definition: authenticated users in any department, including users with no department, may receive its individual operations through effective form ACL. It does not make the form anonymous.
- `form_submissions` inherits the boundary of its `form_id` and does not duplicate a department column. Consequently, the generic submission collection of a shared form is also one shared collection for principals granted `ManageSubmissions`; use a department-owned form or a department-scoped Data Model when submission rows must be isolated by company.
Only an Administrator can publish a null-department form; ordinary creators are always assigned their current department. This rule avoids a growing whitelist for operational forms. The explicitly classified startup, login, shell, licensing/schema, and security-administration set remains only for hard-coded protection needed before or around normal ACL evaluation. Anonymous Viewer access is a separate publication mechanism controlled by `metadata.isAnonymous`: a null department shares a definition with ACL-authorized system users, while `isAnonymous` permits unauthenticated read.
Migration `FormPlatform.Core/011_department_multitenancy` creates the hierarchy and user/form ownership columns. `FormStorage` may use a separate database, so `form_definitions.department_id` is validated by domain services rather than a cross-database foreign key.
Effective form access
For a non-administrator system user, FormPlatform evaluates access in this order:
1. Read the form definition's department. A null value is a shared definition and skips only the form-ownership check.
2. For a non-null value, resolve the user's active department and active descendant IDs and require the form department to be in that set, unless the form is explicitly classified as platform infrastructure.
3. Apply matching ACL deny rules; deny has priority.
4. Apply Read, Edit, Delete, and ManageSubmissions grants and supported built-in role defaults.
`Read` means using the form: rendering it, submitting it, and executing business-data operations declared by its trusted schema. `Edit` means changing the definition in Designer. `Delete` removes the definition. `ManageSubmissions` manages generic JSON submission history and can authorize Survey assistance. Design permission does not imply submission-management permission.
Operational System and Functional forms, including Survey administration, notifications, Submission Center, and Module Starter, do not receive a blanket Administrator check. A department-owned definition requires both matching department scope and effective ACL; a null-department definition is shared but still requires effective ACL. Form Center's database fast path and the per-form evaluator use this same rule.
Creating and updating forms
- An ordinary creator cannot select ownership; the server stores the creator's current active department.
- An Administrator may select any active department or leave it empty. Empty explicitly publishes a shared form definition; it does not bypass ACL or share department-scoped business rows.
- Only an Administrator may change an existing form's department. Ordinary updates preserve the stored value.
- UI visibility is not a security boundary; direct API requests use the same checks.
The client obtains creation state from `GET /api/forms/create-access`. Administrator department choices come from `/api/admin/system-access/departments/options`.
In Designer, `displayName`, `description`, `isActive`, `hidden`, and department ownership are edited together in **Form settings / Action Code**; the technical `name` remains in the top toolbar. The server still preserves department ownership on ordinary-user updates even if a request is forged. `form_definitions.hidden` is a discovery flag, not an authorization boundary: it defaults to false, removes the form from non-Administrator Form Center queries before pagination, and remains visible to Administrators. It does not grant or revoke ACL operations, change department scope, disable the form, or block an authorized direct URL. Relational FormStorage creates or repairs this column idempotently in its own configured database; it is deliberately not a Core migration tied to the possibly different Management data source.
Automatic ORM row scope
Department scope is a server convention and is not configurable in a form or control:
- The Data Model whose physical table is `app_departments` is filtered by its physical `id` column.
- Any other Data Model that exposes a physical `department_id` column is filtered through the logical attribute mapped to that column (normally `departmentId`).
- A Data Model with no physical `department_id` column is global and receives no automatic department filter.
The decision comes from trusted Data Model metadata. `departmentScopeProperty` is not a supported security setting in DataGrid, ItemRenderer, AsyncSelect, Tree, or `metadata.mapping`; old saved JSON containing it is ignored.
For a scoped model, the server combines predicates with `AND`:
configured business filter
AND department_id IN (current department and active descendants)
AND search/cursor predicates
The same convention covers DataGrid, ItemRenderer, AsyncSelect, Tree, entity detail load/list, route-key load, insert, update, copy, and delete. Selected joined references are scoped as well, and mutations validate declared Data Model references so a forged identifier cannot create a cross-company reference.
For ordinary users, a scoped row with `department_id IS NULL` is invisible, cannot be loaded by key, cannot be updated/deleted/copied, and cannot be referenced. There is deliberately no `OR department_id IS NULL`. Administrator can see and repair such rows. On ordinary insert through either a Data Model form or a platform API, a missing, null, empty, or whitespace-only client department value is normalized as unassigned and filled with the actor's current department before scope validation; if no valid department is available, the write fails. Ordinary updates cannot clear the value or change it outside the allowed subtree.
The original unscoped `IFormEntityDataService` signatures remain for binary compatibility. The built-in implementation rejects those legacy calls when the target model has a department boundary. Replacement implementations must implement the overloads that accept trusted `departmentIds` before serving non-administrator requests.
Do not use `{user.departmentId}` or a client filter as the security mechanism. Requests can be modified; the allowed ID set is resolved from the authenticated principal on the server.
System notification delivery
`system_notification` deliberately has an explicit publication rule for respondent-page delivery; it does not change the generic ORM rule above:
- A row whose `department_id` is null is a global notification. If its location, active flag, and time window match, it may be shown to every visitor or respondent on that configured respondent page.
- A row assigned to department D is shown only to an active local respondent whose active department is D or an active descendant of D. The server derives this by walking upward from the respondent's stored department and never accepts an audience department from the browser.
- Before local respondent authentication, the login page has no trustworthy department identity. External identities and local respondents with a missing, inactive, broken, cyclic, or unreasonably deep department path also have no trustworthy internal scope. These cases receive global notifications only.
This exception applies only to the read-only respondent notification endpoint. Management still uses the normal Data Model boundary: an ordinary system user can query and mutate notification rows only inside their department subtree, cannot manage a null-department row, and an empty department on insert is assigned to that user's current department. Only an Administrator can intentionally create or manage a global notification.
`FormPlatform.Core/013_core_business_tenant_audit` is released, checksum-locked migration history and retains its original one-time backfill of an existing null notification department from `created_by`. Do not edit that migration or its recorded checksum to implement the newer publication meaning. A fresh database has no notification rows when 013 runs, so later global notifications remain null. In an upgraded database, a department value previously inferred by 013 cannot be distinguished safely from an intentionally targeted notification; no later migration clears it. An Administrator must explicitly clear only the records that are known to be global.
Department field reconciliation
After database migrations and all first-party/extension form initializers have run, the host performs an idempotent reconciliation for Data Model-backed forms. For each form it inspects the form's effective data source and requires both of these facts before making any change:
1. The mapped physical table actually contains `department_id`.
2. The trusted Data Model exposes that column through a logical attribute, normally `departmentId`.
If the form does not already contain a data control bound to that logical property (the physical `department_id` alias is also recognized), the reconciler inserts a text input labelled `Department ID` before the root submit button, or at the end of the root component list when no submit button exists. Existing controls and Designer layout are not replaced. When the form uses explicit `metadata.mapping.attributes`, the same pass adds the missing direct attribute mapping so the field participates in load and save. The pass validates only the department binding it owns; an unrelated legacy mapping cannot block this repair and is never silently rewritten. Forms generated later from a Data Model already receive the writable attribute through the normal generator.
This field is an administration/data-entry aid, not the security boundary. Leaving it empty lets the server assign an ordinary creator's current department on insert; entering or forging an ID outside the allowed subtree is rejected. If the physical schema and Data Model disagree, the form is left unchanged and a startup warning asks the Administrator to refresh the Data Model. A model without physical `department_id`, an API-backed form, and the special `app_departments.id` hierarchy do not receive this text input.
API-backed data
Automatic detection applies only to platform ORM/Data Model paths. An `apiUrl`, module endpoint, or custom application service must use `IDepartmentScopeResolver` and apply the boundary to every query and mutation itself:
var userId = http.User.FindFirstValue(ClaimTypes.NameIdentifier);
var scope = await departments.ResolveAsync(userId, ct);
// Compile scope.DepartmentIds into the API's own query; never accept scope IDs from the client.
An operational form API must also use `IFormAccessAuthorizer` for the owning form:
var allowed = await formAuthorization.AuthorizeAsync(
owningFormId,
http.User,
FormAccessOperation.Read,
ct);
if (!allowed) return Results.Forbid();
Form ACL and row scope are both required. The ACL cannot replace data filtering, and data filtering cannot grant use of a form.
Chat, Commerce, and ResourceBooking modules
The first-party/custom modules apply the same convention to their `apiUrl`
routes rather than relying on generic ORM endpoints:
- Chat rooms require `department_id`; room management requires Read on the
hidden `System_Chat_Management` form and is bounded by the manager's active
department subtree. Membership references are limited to the room subtree,
and a participant's stored membership department must match the user's
current department. Changing a room department clears all members.
- Commerce applies department scope only to product-management queries and
product create/update/delete operations. They require Read on
`Shop_Products_Admin` and the caller's department subtree. The anonymous
Catalog is an intentional aggregate marketplace: it lists every active,
department-assigned product across tenants, and its form department is not a
filter. Cart and checkout use that same public-product rule. Orders and
payments retain their existing platform-level boundaries.
- ResourceBooking scopes both resources and bookings. Custom APIs require Read
on the relevant Portal, Request, or Administration form. A booking inherits
the selected resource department, and changing a resource department updates
its bookings in the same transaction.
In all three modules, null legacy business-row departments are not shared.
Ordinary users cannot list, address, or repair those rows; Administrator can
assign a valid department. The module migrations append new immutable history
and do not edit released migration SQL.
Offline Survey API
`FormPlatform.Offline` does not accept a department or response identity from the browser. Respondent issuance records the exact deployment/version, form, respondent identity kind, department, fingerprint, response ID/version, and device in a server-owned grant. Assisted issuance additionally records `authorization_mode = assisted`, the authenticated system `operator_id`, and the explicitly selected assigned `respondent_id`. Synchronization resolves those facts only from the grant and requires the same respondent identity or the same system operator, respectively. The Host again requires the deployment, list, respondent, membership, and current operator scope to agree. A grant's `department_id` is an audit boundary, not client-selectable authority and not an `OR department_id IS NULL` sharing path.
Offline Data Model AsyncSelect/Tree snapshots call the same department-aware query overload as the corresponding online mode and therefore cannot widen row scope. Respondent mode uses the respondent convention; assisted mode supplies the current operator's allowed department IDs. The package contains only that complete authorized result; its server-side manifest binds the item count, SHA-256 version, limit, and expiry. Synchronization re-runs the query under current scope and rejects a changed set or a submitted key outside it. Models without physical `department_id` remain global exactly as they are online; scoped models do not become global because they are downloaded.
The `/offline` and `/offline/assisted` shells remain reachable without an online login check so encrypted packages can open after a network loss. This does not make Survey data public: respondent APIs require the isolated Respondent policy; assisted APIs require a system login plus current workspace/form ACL and department scope; IndexedDB payloads remain encrypted while locked; and the service worker never caches Survey API responses.
Department hierarchy rules
The recommended hierarchy uses a platform root, company roots directly below it, and departments below each company. The number of levels is not hard-coded. Server management prevents:
- placing a platform root below another department;
- moving a company root below another company or to another platform root;
- moving any existing department across company or platform-root boundaries;
- cycles and self-parenting.
Inactive departments do not enter an ordinary user's scope. A department with children, assigned users, or assigned forms cannot be deleted. Imports and identity synchronization should keep department codes stable, but authorization always uses IDs.
The UI not offering an out-of-scope reference is only a usability feature. Server-side write validation remains mandatory because direct HTTP calls, imports, triggers, and future clients can submit identifiers without using that UI.