> 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/access-modes.md).

# Access modes and the administrator site

An Admin user works in one of two modes, Admin or Client.

## 1.6 Access modes and the administrator site <a href="#id-16-access-modes-and-the-administrator-site" id="id-16-access-modes-and-the-administrator-site"></a>

An Admin user works in one of two **modes**, Admin or Client. The modes are **separate views of the product, not a filter** — and the mode is **derived from the route, never chosen**. There is no Admin/Client switch.

`access.access_mode_for_request` asks `access.route_mode_for_request` what the resolved route belongs to and answers with that. A console page is admin-mode because it is a console page; `/client/` is client-mode because it is the client area. Client accounts never reach Admin, and Ticketing only is pinned to Admin because it has exactly one area ([§1.7](/platform/identity/staff-roles.md#id-17-staff-roles-ticketing-only-support-and-super-admin)).

**Why the switch went away, and what went with it.** The old mode was a session value an operator set from the sidebar, which meant the session and the URL could disagree. `AccessModeRouteMiddleware` existed to reconcile them: a mismatched GET was redirected, and a mismatched non-GET was refused with a 403 because `@admin_required` checks `access_level` and never consulted the session — without the refusal an Admin sitting in Client mode could POST to every console endpoint and `record_audit_event` stamped those writes `actor_mode=client`. **A route cannot disagree with itself**, so removing the switch removed the mismatch rather than papering over it, and `actor_mode` is now the mode of the route that did the writing.

What that middleware still does is *record* the route's mode (`access.record_access_mode`). Nothing else writes it and nothing may: the moment a mode can be asserted from a form, a query string or a header, the `actor_mode` guarantee above is gone. It exists for the pages that belong to **both** areas and so cannot answer for themselves — `access.SHARED_PATH_PREFIXES` (`/console/account/`, `/console/django-admin/`) plus `access.SHARED_URL_NAMES` (ticket attachment downloads). Those inherit the area the session was last in, which is what stops an operator's own profile page flipping their shell.

`access.ADMIN_ONLY_PATH_PREFIXES` is the exception to that exception, checked first: `/console/account/users/` and `/console/account/staff/` are admin-mode, not shared. Setting another person's password or access level is an admin write, and leaving it unclassified would both mis-stamp it and wave it past the Support allowlist. **Anything else added under `/console/account/` that acts on a&#x20;*****different*****&#x20;user belongs in that tuple too.**

Client ticketing under `/console/tickets/` is a **client-mode** route space: non-`admin-` url names in the tickets app resolve to Client mode, so an Admin can still reply to their own ticket there.

The one redirect left in `AccessModeRouteMiddleware` is a **role** rule, not a mode one: Ticketing only has a single area, so a client-mode route is sent back to the queue ([§1.7](/platform/identity/staff-roles.md#id-17-staff-roles-ticketing-only-support-and-super-admin)). Every other staff role simply *is* in the client area when they open a client page.

`AuditAction.ACCESS_MODE_SWITCHED` survives so historical rows stay readable and is excluded from the audit list, its action filter and its actor options. Nothing writes it any more. Authenticated console/client HTML is still sent `no-store`, so the back button cannot restore a shell rendered under the other mode.

**Crossing between the areas** is an ordinary link in the account menu — "Client area" from the console, "Admin area" from the client portal — built by `access.area_urls_for_request`. `admin_area_url` is blank for anyone who is not staff, so a customer is never shown a door that answers 403. Hiding a link is not an access control; `@admin_required` is.

### The dedicated administrator hostname <a href="#the-dedicated-administrator-hostname" id="the-dedicated-administrator-hostname"></a>

`accounts.AdminSiteConfig` (Settings → Admin site) gives the console its own hostname, the way `tickets.TicketingConfig` does for the workbench ([§8](/platform/support.md)). DB-backed rather than an environment variable, because operators change a hostname far more often than they redeploy. **Default off and blank**: until a super admin names a host and enables it, the console is at `/console/` on the main domain exactly as before.

Once it is on, `AdminSiteHostMiddleware` splits the two areas across the two hostnames, and *that* is what makes the derived mode above unambiguous — the area is decided before the path is even read:

* `/console/…` on the customer domain **redirects** to the same path on the admin host. Client-mode routes on the admin host redirect back to the main site. The root of the admin host redirects to `core:home`, which already answers "where does this account belong".
* **Only GET and HEAD are redirected.** A cross-host 302 on a write loses the body on most clients and the host-scoped session and CSRF cookies on all of them, so "redirecting" it really means answering it with a CSRF failure somewhere else. A write aimed at the wrong host is a **403 naming the right one**.
* Classification is unchanged by the hostname, because **the console is not re-mounted**: it keeps its `/console/` paths on both hosts. `console/…` routes are declared inside seven app URLconfs that also carry public routes (`core`, `marketing`, `assistant`, `recordings`, `paas`, `billing`), so serving them at `/` would mean splitting each of those apps' URL namespaces in two and every `{% url %}` and `reverse()` with them. The hostname is worth having on its own; the shorter path is not worth that. This is the one place the admin site differs from the ticketing one, which *is* a clean re-mount.
* **The hostname joins `ALLOWED_HOSTS` at request time** (`config.allowed_hosts.AllowedHosts`, a list subclass whose iteration appends both configured hostnames), so naming it in the console is sufficient — a setting that needs a redeploy to take effect is a setting that silently does nothing. It can only ever add **literal hostnames**: the field is validated by `core.validators.validate_dedicated_host`, a grammar with no wildcard, no leading dot, no scheme and no port, so no row in either table can widen host checking the way `"*"` would. The environment list stays the base, each lookup fails closed independently, and the value is cached for 60 seconds with the save clearing it.
* **The two hostnames may not be the same.** Both features set routing by host; one host cannot be two sites, and the clash would be silent, so each model's `clean()` refuses the other's active hostname.
* **Deliberately left alone.** The ticketing hostname keeps serving exactly what it serves today, and the standalone ticketing *path* mount is not relocated either — that app has its own dedicated-domain setting, and moving it under this one would take that choice away. Sign-in (`/account/`), the client API and static files are answered on the admin host rather than redirected: the host needs a door, and a 302 is the wrong answer to a machine caller.
* **Browser sessions are host-scoped**, so staff sign in again the first time they open the admin domain unless `SESSION_COOKIE_DOMAIN` is set to the parent domain. The Settings tab says so.
* **Auth that leaves and comes back has to return to the host it left**, for the same reason. `oidc.callback_url` and `passkeys.expected_origin` both ask `accounts.hosts.dedicated_host_for_request` and answer with *this* hostname rather than the public site — see [§1.14](/platform/identity/audit-and-oidc.md#id-114-openid-connect). Registering the extra redirect URI with the provider is the one operator step enabling this setting adds, and the Settings tab prints the exact URI once the hostname is saved.
* Where the main site is, for the return trip, is read from `ALLOWED_HOSTS` rather than stored a second time (`accounts.hosts.main_host`) — so a staging deployment sends its own staff back to its own domain instead of to whatever production calls itself. `PUBLIC_SITE_BASE_URL` is the fallback for a deployment whose `ALLOWED_HOSTS` is nothing but wildcards.


---

# 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/access-modes.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.
