> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/identity/organizations.md).

# Organizations and tenants

create form, no personal-workspace shape, and joining somebody else's imports nothing.

## 1.10 Organizations and tenants <a href="#id-110-organizations-and-tenants" id="id-110-organizations-and-tenants"></a>

**Every account owns an organization.** It is not an opt-in feature, there is no create form, no personal-workspace shape, and joining somebody else's imports nothing.

`tenancy.ensure_organization_for_user` is the whole mechanism: idempotent, and free once `organization_id` is set. It is called from every account-creation path (registration, placeholder claim, console client creation, `get_or_create_billing_account_for_user`, the dev seed) and again from `billing_account_for_request`, which `ActiveTenantMiddleware.process_view` runs on every browser view — so a row written before this rule, or by a path that forgets, acquires one on its next request instead of rendering a portal with no workspace in the picker. Because the repair is skipped outright once `organization_id` is set, the guarantee that an ordinary client-page lookup does not lock the account row still holds.

Three things it must never do:

* **Create a `BillingAccount` for a login that has none** (most staff), or opening a console page would mint a billing account for every staff member.
* **Give a&#x20;*****member*****&#x20;a second organization.** Their own account is left unanchored on purpose while they belong to somebody else's, and that is what keeps its billing history out of the portal.
* **Mint a second billing account for a login that already has one.** A missing `primary_user` stamp is repaired on the original row. Creating an empty stand-in would hide every invoice, saved card, statement and ledger entry while those rows still sat on the legacy account.

**Cutover.** Existing accounts, invoices, saved cards, ledger rows, orders, payment attempts and statements (computed from the ledger) stay on the same `BillingAccount` row. `billing.0090` / `billing.0092` attach that row as the organization's first tenant and payer; they do not copy, revoke, or delete it. A closed, suspended or KYC-held account is included, because refusing would leave that customer with history they can no longer open.

VMs, firewall groups, private networks, web hosting services, KYC, win-back and API usage already hang off that account, so they follow it. Affiliate and referral partners, their codes, referred clients, commission ledger and payout details hang off the user; the owner keeps them because they own the new organization. API keys and tickets have their own organization columns: `billing.0093` (and `attach_legacy_records_to_organization` when an organization is first created) binds the owner's existing keys and tickets without revoking the key or deleting the conversation. A key minted before the login had an account stays unbound. Guest-token tickets stay personal so emailed links keep working.

A suspended or KYC-held account still gets one, deliberately: the organization confers no spending authority (`billing_hold_account` is still the gate) and refusing would leave a held customer with no workspace, no invoices and no way to pay.

Naming is derived rather than asked for — the company on the billing account, else the person's name, else the local part of their address; the first tenant is `Main`. Both are editable afterwards (`rename_organization` from Organization settings, `rename_tenant` from the Tenants page).

One exception to "staff get nothing": an active super admin is given an account, an organization and a **Development** tenant pinned to the selected development hypervisor, so they can deploy onto test hardware ([5.23](/platform/vps/marketplace.md#id-523-development-hypervisors-and-the-development-tenant)). It happens at sign-in and when their role is written, never during a page render, and only when a Development hypervisor is actually selected.

**Tenants.** A tenant is a named `BillingAccount` inside an organization. `BillingAccount.user` identifies the tenant's owner; `primary_user` preserves the unique original account and the `user.billing_account` reverse relation for identity and personal-history consumers. **Client resource code must use `billing_account_for_request` or `active_billing_account_for_user`, never that personal identity relation.**

**Invitations.** Inviting asks for an email address, not an existing login. The same form emails a link whether the address already has a LayerOne account or not; if it does not, they create one with that address and then open the link. Everything else is settled when the recipient clicks Join, and that split is deliberate — "already belongs to an organization" is now true of everybody, so testing it on the invite form would reject every invitation, and reporting a stranger's account state to whoever typed their address discloses that account.

`_invitation_issue` holds all of it: the recipient belongs to another owner's organization; their own has other members or extra tenants; their account still has an active service (`ACTIVE_SERVICE_JOIN_MESSAGE` — they delete their servers first); or a payment is part-way through a provider. Stopped, provisioning and suspended servers count, as do hosting services awaiting removal, live subscriptions, unreleased IPs and pending provisioning jobs.

An invitation sends one email (`apps/billing/organization_email.py`), a **transactional** send through `send_configured_mail` rather than a campaign: there is no audience, no schedule and nothing to unsubscribe from, and routing it through the marketing category would let the suppression list and quiet hours withhold somebody's invitation. The link opens the invitation *page*, not an accept endpoint — accepting is a CSRF-protected POST, and the page is where the cost of joining is disclosed. Invitations are addressed to an email, expire after seven days, and only the signed-in account for that address may accept; only the owner may revoke. A token alone never authorizes acceptance. An address with no account yet is stored without an `invitee` until they register; creation paths bind outstanding invitations to the new login.

**What joining does and does not move.** Accepting adds a membership and nothing else:

* Their servers and services never move; they delete them first.
* Their saved payment methods are **revoked, not deleted** (see [4.6](/platform/billing/payments.md#id-46-payment-methods)). A member cannot reach billing controls at all, so a card left there is a credential nobody can see or account for while auto-recharge still keys off `is_default`.
* Their invoices, statements and ledger entries stay exactly where they are, on an account nothing can reach: kept because that is the customer's financial history, hidden because they are a member now.
* Their own organization is **dissolved, not deleted** — it still owns the support conversations opened under it, which stay visible to its former owner and to nobody else.
* Their credit does not join the pool: `account_credit_balance` sums the organization's tenants and the left-behind account is not one of them.

`remove_organization_member` gives the member an organization back, with their account, invoices and ledger re-anchored to it. It is a **new** organization rather than the one they closed, so the tickets from before are not exposed to whoever they invite next.

Locking order covers two organizations: member identity, then the accounts in pk order, then both organizations in one pk-ordered statement, then the invitation. Two joins crossing in opposite directions would otherwise take the organization rows in opposite orders.

**Consequences elsewhere.** Because a left-behind account is unanchored and still names its owner in `BillingAccount.user`, **any "this row is mine" shortcut that skips the membership test is a hole**. `user_may_access_console_session` had one ("no organization and its `user_id` is you") and now also asks `user_can_access_tenant`. `payment_method_authorization_is_current` had the mirror-image problem: its legacy branch returned `account.organization_id is None`, which is now never true, so it asks the question it always meant — is the account still its own payer, still owned by the login that started the checkout.

Owning an organization no longer says anything about whether billing is pooled, so the "Organization Billing" label is driven by `organization_is_shared` (more than one visible tenant).

**Shared billing with tenant attribution.** Ledger entries, usage, orders and invoices retain their originating tenant. `account_credit_balance` sums the organization's tenant ledgers; `tenant_credit_balance` reads one tenant's contribution. Shared-balance decisions serialize on the organization row in addition to the existing account locks. The original tenant is the payment account; automatic recharge calculates funding for the organization and runs **once per organization**, avoiding a separate charge from every tenant. Billing management is **owner-only** (balances, statements, payment methods, deposits, PayPal management) and the check is the user's organization role, not which tenant they submit from. Members can deploy and manage tenant services using shared credit. **Rewards standing is organization-wide**: members open Rewards program and see the same tier, streak and bonuses as the owner; they cannot add credit. Bandwidth, resources and API keys stay tenant-scoped. ISO images are organization-wide: one 10 GB library shared by every tenant ([5.19](/platform/vps/images-and-backups.md#id-519-iso-library)). Backup storage is also organization-wide (one purchased pool billed on the payer); vzdump backups listed on Images stay tenant-scoped ([5.20](/platform/vps/images-and-backups.md#id-520-backups-and-snapshots)).

**Request and credential isolation.** The session stores an explicit tenant ID; middleware resolves it against current ownership/membership on every browser request and leaves `request.user` unchanged. Invalid or revoked selection falls back on safe reads and **writes are refused** rather than replayed against another account. Tenant switches are CSRF-protected POSTs with a fixed destination. The shell context includes the selected tenant, forcing a document refresh when partial navigation comes from an old tenant shell; browser forms carry the tenant they were rendered for, so a stale form is refused if another tab switched. API keys store their tenant at creation and authentication rechecks membership, so revoked membership revokes effective API access. VNC sessions recheck organization access on connection and every 10 seconds while connected. Session recordings store the actual tenant and start a new recording after a switch.

**Navigation.** The client command rail opens with **Tenants** when an organization is selected (collapsed by default; current workspace, switch POSTs for the others, and **Add tenant** for owners), then **Manage** (Instances, Images, Networking, Aegis) and **Account** (Billing, Rewards program, Refer and earn, Support, Organization settings, Account), each framed by a hairline. Deploy lives in the client top bar via **Create**. Groups are native `details` elements; Manage and Account default open, Tenants default closed. Expansion is remembered in browser-local storage and the group that contains the current page opens on navigation. **Organization settings** opens a section sidebar with five routes: **Organization Settings** (name, billing, invitations received), **Sign-in** (OpenID Connect for the owner's email domain), **Tenants**, **Members** (members, tenant access, invitations sent), and **API** (organization API keys; owner-only create). API access is not an Account section. Tenant deletion lives on the Tenants page. Each page keeps its own url name so `staff_access` and the route-ownership sweep can tell them apart.

Tenant deletion refuses any tenant holding a service, resource, API key, usage counter, ledger entry, invoice, payment/provider record or other retained relation — including zero-value and terminal history. The original/payer tenant cannot be deleted.

## 1.11 Per-member tenant access <a href="#id-111-per-member-tenant-access" id="id-111-per-member-tenant-access"></a>

An owner assigns a member **all tenants** or **a named list**, from the Members tab on Organization settings. `OrganizationMembership.tenant_access` is the switch (`all` / `selected`) and `OrganizationTenantGrant` is the list.

The mode is read **first**: a `selected` membership reaches its grants and nothing else; an `all` membership reaches the organization, so a grant row is never consulted. Switching back to **all** therefore **deletes** the grant rows rather than parking them — keeping a curated list across a toggle would leave rows that mean nothing while they exist, and a reader that forgot the mode check would then over-grant. Cleared, the same mistake reads as an empty set and refuses.

The two modes differ in what a *later* tenant does: **all** follows the organization, a named list is fixed. That is the direction that fails closed, and it is why the default for a new membership is **all** — nothing changes for an organization that never opens this control.

Enforced in `accessible_tenants` (`apps/billing/tenancy.py`) and **only there**. Every tenant-authority consumer funnels through it (`user_can_access_tenant`, `user_has_active_tenant_access`, `billing_account_for_request`, `switch_tenant`), so bearer-token API access, the VNC websocket recheck, client-view resource ownership and the tenant picker narrow together. The restriction and the granted set resolve as `Exists` subqueries inside the affiliation query, not a second lookup, so a grant change cannot slip between two scope queries.

Two deliberate omissions: it never restricts the **owner**, whatever their membership row says; and a grant is only honoured for a tenant still inside `accessible_organizations(user)`, so a stale row naming a tenant elsewhere grants nothing.

`set_member_tenant_access` is the only writer: owner-only, refuses the owner's own membership, resolves submitted ids against the organization's own tenants, requires at least one tenant under `selected`, audits `organization.member_access_changed`, and takes the member identity lock before the organization lock (the order the rest of the module uses).

Consequences: the picker and Tenants page list only what the viewer may switch to; `billing_account_for_request` falls back to the first accessible tenant for a restricted member; narrowing a grant revokes that member's API keys bound to the dropped tenant; and `tenant_deletion_issue` refuses to delete a tenant that is some member's **only** workspace. Billing authority is unchanged and still keys off the organization role.

## 1.12 Creating, editing and deleting a client account from the console <a href="#id-112-creating-editing-and-deleting-a-client-account-from-the-console" id="id-112-creating-editing-and-deleting-a-client-account-from-the-console"></a>

`/console/billing/clients/new/` (`billing:admin-client-create`) writes the same two rows registration does — a `User` at `AccessLevel.CLIENT` and its `BillingAccount`, in one transaction, through `apps.accounts.services.create_client_account`. A `User` with no billing account is invisible to the Clients list and cannot be quoted, credited or provisioned for, which is the gap this closes, so the two writes cannot come apart.

It deliberately carries none of the self-service machinery: **no rate limiting** (the caller is an authenticated admin and the per-IP success cap would throttle an operator opening several accounts), **no referral attribution**, **no login**.

**The password is typed here, not emailed.** `request_password_reset` is silently rate limited per email *and per IP* and returns nothing either way, so an operator creating two accounts in a row would have the second customer never receive anything while the console reported success twice. Worse, the row left behind meanwhile has no usable password — precisely what `is_claimable_placeholder_account` treats as claimable — so whoever registered that address first would take over an account staff may already have credited.

If the email already belongs to a passwordless placeholder the row is **claimed** rather than duplicated (the email is unique, so there is no "create anyway" branch). Names already on the row are kept, not overwritten. The success message and `after["claimed_placeholder"]` record which of the two happened.

Off `SUPPORT_CONSOLE_ROUTES`: it mints a credential the operator then knows, which is the credit-adjustment class of power. `billing:admin-client-list` *is* Support-readable, so the **New client** button is wrapped in `{% if not console_nav_is_support_staff %}`.

**Editing identity.** `BillingAccountAdminForm` at `/console/billing/clients/<id>/edit/` owns **Sign-in email**, **First name** and **Last name** as well as the `BillingAccount` fields, because `admin_user_detail` redirects to the client detail page for any user with a billing account — so this is the only page those `User` columns can be edited from. The form renders as groups (`FieldsetsMixin`), and `FIELDSETS` also fixes the order because `ModelFormMetaclass` appends declared fields after model ones.

`User.email` is the whole identity: `USERNAME_FIELD`, what `sign_in_with_password` looks up, and the address password reset proves control of. `billing_email` is a different column — where invoices, receipts and lifecycle notices go — and an account billed to a separate `accounts@` inbox is supported. **The exception:** if the submitted `billing_email` still equals the *previous* sign-in address, moving the sign-in address moves it too. Registration copies one into the other, so the two being identical means nobody separated them; leaving the billing address behind would walk the account into suspension with every warning landing in an address nobody reads. It is reported in the success message rather than done quietly.

**An unsubscribe follows the person, not the string.** `MarketingSuppression` is keyed by address, so without a carry-forward a client who opted out at `old@example.com` starts receiving campaigns the instant an operator corrects their address — and the unsubscribe page promised in writing they would not. `carry_suppression_to_new_address` runs for **both** addresses (the recipient resolver reads `billing_email` first) and only ever *adds*.

Deliberately absent: **sessions are not revoked** (Django derives `get_session_auth_hash` from the password, and signing a customer out over a typo correction they asked for is a worse surprise), **no mail is sent**, and a **collision is not merged** — `User.email` is unique and a placeholder owns its own ticket history, which can hold plaintext credentials.

`billing:admin-client-edit` is off `SUPPORT_CONSOLE_ROUTES`, which is doing real work: changing the sign-in address and then triggering a password reset is account takeover. `AuditAction.USER_SIGN_IN_EMAIL_CHANGED` is its own action, and an edit that moves nothing records nothing.

**Deleting.** `/console/billing/clients/<id>/delete/` (`billing:admin-client-delete`) purges the billing account from the Clients list. Off `SUPPORT_CONSOLE_ROUTES` for the same class of power as create: it destroys the credential. The button on the client page is wrapped in `{% if not console_nav_is_support_staff %}`.

Live infrastructure is refused, not destroyed. Proxmox guests and hosting node accounts have their own destroy paths; a console delete that skipped them would leave billed hardware with no owner. Active PayPal credit subscriptions, other organization members, and a referral-partner row are refused the same way — those have their own close paths.

When the row is the original personal account (`primary_user` set), the sign-in goes with it: extra tenants of the owned organization, tickets, billing history and the `User` row. Extra tenants in the Clients list are workspaces, not logins; deleting one of those removes that tenant only. `AuditAction.USER_CLIENT_ACCOUNT_DELETED` names the email so the purge is findable after the row is gone.

The customer-facing unused-tenant delete (`tenant_deletion`) is the opposite policy: it preserves liabilities. Admin delete is staff removing the customer, so PROTECT history is collected and removed rather than treated as a reason to keep the account.

The purge locks the `BillingAccount` row with `select_for_update(of=("self",))`. `organization` is nullable, so `select_related("organization")` is a LEFT OUTER JOIN; PostgreSQL refuses `FOR UPDATE` on that nullable side (`NotSupportedError: FOR UPDATE cannot be applied to the nullable side of an outer join`). Local SQLite does not. Do not drop `of=("self",)` to simplify the lock — production is PostgreSQL.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/identity/organizations.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
