Bolt CMS Docs
Sign in

Platform

Organizations

A core multi-tenancy and authorization layer — an organization hierarchy, email-linked staff with permission grants, inter-organization connections, and the predicates that answer who may act on which organization.

What organizations are

Organizations is the core multi-tenancy and authorization layer. Authentication establishes who the signed-in actor is; this layer answers the next question — who may act on which organization, and with what permissions. It is built into core (includes/bolt_organizations.php), loaded automatically for every /api/ request, and safe to require from a page.

  • Organizations form an integer parent_id hierarchy. The integer organization_ID is the canonical scoping key — every organization-scoped row you create should carry it — and a stable uuid is stored alongside it.
  • Staff members link a person to an organization by email, optionally as an admin, with a list of permissions. Staff are the organization's operators — they grant management access.
  • Contacts are real people — signed-in accounts — who have interacted with the organization (registered for an event, submitted a form, and so on). They are recorded automatically so the organization can look them up later. A contact is not a staff member and is never granted any access.
  • Connections are directed links from one organization to another (affiliations, partnerships, and the like).

Who is a member, who is an admin

Membership is not a separate user list — it is derived from the signed-in account. The actor's verified email addresses are matched against an organization's active staff, and three rules decide access:

  • Super administrators (a Bolt account with is_admin) administer every organization and hold every permission.
  • Organization admins are active staff with the admin flag whose email matches one of the actor's verified contacts. Admin rights inherit down the parent_id chain — an admin of a parent organization administers all of its descendants.
  • Everyone else is granted only the named permissions on their own staff record, on the specific organization it belongs to.

Backend requirement. Email-linked membership reads verified contacts from the SQL database auth backend. On the flat-file backend there are no email identities, so the layer degrades to super-admin-only: the read endpoints still answer, but only a super admin is ever an organization admin.

Two questions: “is an admin” vs “can do X”

The whole layer turns on two predicates, and they behave differently on purpose:

PredicatePasses whenInherits?
org_is_organization_admin($orgId)super admin, or an active-admin staff email matches the actor. Any non-deleted organization.Yes — up the parent_id chain.
org_user_can($orgId, $permission)super admin, or $permission is in the actor's permissions. The organization must be active.No.

Both return a plain false for an anonymous visitor — they never throw and never redirect, so they are safe to call anywhere. Pass an array of permissions to org_user_can() for an any-match (OR) test.

Asking from PHP

From a page or any server-side code, call the read predicates directly. They are keyed by the current session, so you pass an organization id, never a user:

require_once ROOT_DIR . '/includes/bolt_organizations.php';
// (in an API handler the library is already loaded for you)

if (org_is_organization_admin($orgId)) {
    // full management of this organization (and its descendants)
}

if (org_user_can($orgId, 'manage_staff')) {
    // holds a specific permission on an active organization
}

if (org_user_can($orgId, ['manage_staff', 'manage_billing'])) {
    // OR: holds at least one of these
}

$permissions = org_get_user_permissions($orgId);   // string[] for this actor
$children    = org_get_child_ids($orgId);           // direct child organization ids

Gating an endpoint to organization admins

In an API handler — or a resource's _bootstrap.php to cover a whole namespace — use the guards. They emit the standard JSON error envelope and stop the request, exactly like bolt_require_login():

// api/teams/POST.php — gate a write to admins of the target organization
org_require_login();                 // 401 if not signed in
org_require_org_admin($orgId);       // 401 anonymous / 403 non-admin — never 404
// …safe to mutate this organization's data now…

org_require_org_admin() answers a non-admin with 403, never 404, so it never leaks whether an organization exists. The guards belong in API context (where api_error() is loaded); in a page, branch on the read predicates above and redirect yourself instead.

Asking from JavaScript or another extension

Four read endpoints under /api/organizations/auth/ answer the same questions over HTTP. They are deliberately open: an anonymous caller gets a boolean at HTTP 200, never a 401, so a client can ask before it knows who is signed in.

curl 'https://your-site.com/api/organizations/auth/is-admin?organization_ID=1'
# {"success":true,"is_admin":false}

curl 'https://your-site.com/api/organizations/auth/user-can?organization_ID=1&permission=manage_staff'
# {"success":true,"can":false}

Another extension asks in-process, sharing the same session so the actor resolves identically (see API routing):

$res = bolt_api_request('/api/organizations/auth/is-admin', 'GET', [],
    ['query' => ['organization_ID' => $orgId]]);

$isAdmin = !empty($res['is_admin']);   // a non-2xx or missing key ⇒ treat as NOT authorized

Scoping your own feature to an organization

To make a feature multi-tenant, carry organization_ID on your own data objects, gate every write with org_require_org_admin($orgId), and read rows back filtered by that id. For hierarchy-aware reads, org_get_child_ids($orgId) returns the direct children and org_collect_descendant_ids($orgId) returns the whole subtree. org_manageable_organizations() returns the list of organizations the current account may manage — the data behind the dashboard's organization switcher.

Recording a contact when someone interacts

When a signed-in user does something with an organization — registers for an event, submits a form, makes a purchase — record them as a contact so the organization can find them again. If Jack Davis registers for an event with Top Flight, Top Flight should now have Jack in its contacts table. One call, from your feature's handler, after the interaction succeeds:

// after a successful save in your feature's handler
org_record_contact($orgId);   // records the CURRENT signed-in account as a contact of $orgId

The call upserts a contact keyed by (organization_ID, account_id): the first interaction creates the row with a snapshot of the person's name, email, and mobile; a later one refreshes that snapshot and updates the last-seen time. It is idempotent and safe to call on every interaction, and it quietly does nothing when there is nothing to record (an anonymous visitor, a missing organization, or the flat-file auth backend). Contacts are recorded by the interaction, not by an administrator — over HTTP the same thing is POST /api/organizations/organizations/{id}/contacts, which requires only that the caller be signed in. Crucially, a contact grants no access: it is a record of who dealt with the organization, never a permission.

The admin dashboard

The admin dashboard gains an Organizations tab (no registration — it is discovered automatically). A header organization switcher scopes four views — Settings, Staff, Contacts, and Connections. Settings, Staff, and Connections have add / edit drawers; Contacts is a read-only data table — search it, open a person to look up their profile, or archive a row — with no “add” action, because contacts arrive through interaction rather than by hand. A New organization action rounds it out. There is no public organization profile page; the dashboard is the management surface.

API endpoints

Everything lives under /api/organizations/. Responses use the standard envelope — { "success": true, … }, or { "success": false, "errors": [ … ] } on failure. Management routes are organization-admin gated; the /auth/ reads are open; /admin/ is super-admin gated.

GET    /api/organizations/auth/is-admin       → { is_admin }      (anonymous-safe)
GET    /api/organizations/auth/user-can       → { can }           (anonymous-safe)
GET    /api/organizations/auth/permissions    → { permissions }   (anonymous-safe)
GET    /api/organizations/auth/child-ids      → { child_ids }     (no auth — pure hierarchy)
GET    /api/organizations/organizations       → organizations the actor may manage
POST   /api/organizations/organizations       → create an organization
GET    /api/organizations/organizations/{id}  → one organization (info + meta)
PATCH  /api/organizations/organizations/{id}  → update organization info & branding
.../{id}/staff         GET · POST · PATCH · DELETE   → manage staff (DELETE archives)
.../{id}/contacts      GET · DELETE                  → contacts table / one profile / archive (admin)
.../{id}/contacts      POST                          → record the signed-in caller as a contact (login only)
.../{id}/connections   GET · POST · PATCH · DELETE   → manage connections (DELETE removes)
POST   /api/organizations/admin/seed          → create the default org + staff (super-admin)

A few behaviors worth knowing when you call these:

RouteNotes
POST /organizationsCreating a child (with a parent_id) requires admin of the parent; creating a top-level organization is super-admin only. label is required; the slug is derived and made unique when blank.
POST /organizations/{id}/staffRequires fname, lname, email; a valid, per-organization-unique email (a duplicate returns 409). Updating a staff member links the core accounts behind that email.
DELETE …/staff/{sid}A soft archive (status flips to archived; admin and permissions are cleared). Connections, by contrast, are deleted outright — archive a connection with PATCH status=archived.
POST …/contactsRecords the signed-in caller as a contact — login is enough (a user becomes a contact by interacting; you do not have to be an admin). The reads and the archive on this resource are admin-only. Idempotent: a repeat call just refreshes the snapshot.
POST /organizations/{id}/connectionsRequires connected_with_organization_ID and type; the target must exist and differ from the source, and a duplicate active link returns 409.

There is no organization-delete endpoint; deletion is modeled as a status change.

How it is stored

Organizations persist through BOLT_SQL as four ordinary JSON-defined objects, created on first provision:

ObjectTableHolds
organizationbolt_organizationsThe organization: parent_id hierarchy, label, slug, status; branding and communication:* settings in meta.
organization_staff_memberbolt_organization_staff_membersAn email-linked person; the config JSON column holds the permissions list.
organization_connectionbolt_organization_connectionsA directed organization → organization link with a type and status.
organization_contactbolt_organization_contactsA person who interacted with the organization: the core account_id plus a name / email / mobile snapshot, with first-seen and last-seen timestamps.

Each object carries an id, an indexed uuid, a status, and a meta companion table. Uniqueness and referential integrity are enforced in PHP, not by database constraints. Because these are core objects, address them by their bare names (organization, not organizations:organization).

Seeding the first organization

Provision the four tables from the dashboard's Database tab (preview the drift, then sync), then create a default organization and a super-admin staff member. Both paths run the same importer and are idempotent — if any organization already exists, nothing is written:

# CLI — also provisions the four tables first
php bin/seed-organizations.php

# or, signed in as a super admin (does not sync schema)
curl -X POST 'https://your-site.com/api/organizations/admin/seed'

This creates My First Organization and a Super User staff member (admin@<host>, marked admin). From there, the dashboard's Organizations tab takes over.

See the SQL data layer for the storage these objects use, authentication for the sign-in and verified contacts that drive membership, API routing for calling the endpoints in-process, and extensions for building organization-scoped features on top.

SQL data layer Sending email