# Controllers Reference — Provisioning, Organisation, Group & User

This covers the customer- and staff-facing HTTP controllers across six areas: **BGP provisioning**, **Compute (dedicated-server) provisioning**, **Mercury tunnels**, **Organisations**, **Groups/Permissions (RBAC)**, and **Users**. It's the companion to the auth docs - Those explain *how* a request gets authenticated and authorised, and this one covers what each endpoint actually *does* once it's through the gate.

**Last verified against the codebase: 2026-07-20.** Same conventions as the rest of `Docs/` - Candid voice, per-method entries, and confirmed bugs written down rather than quietly patched over. This automation is load-bearing and cascades fast, so we fix deliberately, with tests. Status labels: **LIVE** (routed & working), **CHILD** (only called by another method), **UNROUTED** (method exists, no route references it), **DEAD** (runs but its result is unused/discarded), **BUGGY** (routed but broken in a specific documented way).

A few things in here are deliberate designs we already know about - `paged_table` count semantics, empty-body error responses, omitted Guzzle timeouts, and `Asns::clean()`'s commented-out peer teardown. They're flagged as intentional where they come up, not re-raised as fresh defects.

---

## Shared plumbing (applies to every controller below)

Read this once; the per-method entries assume it.

- **The proxy pattern.** Our provisioning controllers (BGP/Compute/Tunnel) own almost no state. They resolve Creatia-native identifiers - Org, owned+approved ASNs/AS-SETs, subtenanted prefixes - Translate that into a payload, and hand off to an external microservice over HTTP. The microservice is the source of truth - We only keep a short-lived **Redis** mapping from an opaque per-user handle to the microservice's real id.
- **`bounce:<scope>` (the Bouncer middleware).** Calls `User::canDo([...scopes])`, which needs the user to hold **every** listed scope - Comma-separated scopes are ANDed, so `bounce:compute.bgp.view,management.view` needs both. It logs every hit, writes a `SecurityEvent` on non-GET, and on failure Slack-alerts `HOOK_STANDARD` and returns `401 {"detail":"Insufficient Permissions"}`. The auth docs have the full permission model.
- **Demonstration mode.** When `APP_DEMONSTRATION` is on, Bouncer short-circuits every non-GET request with a canned `demoResponses[$method]`, or a generic `{detail:'SUCCESS',demonstration:true}` - So in demo builds, none of the write methods below actually execute. Several controllers also register `Http::fake()` stubs in their constructor for the demo.
- **`Owned` vs `AdminAny`.** Customer controllers scope to the caller's org - Either the `Owned` trait's `whereOwned`, or `Owned*Identity` validation rules that confirm a resource belongs to `Auth::user()->organisation_id`. Admin controllers drop the org filter and use the parallel `AdminAny*Identity` rules, which validate **existence only** - A `management.*` holder acts across every tenant. That's by design, but it does mean we never check "does this admin own the thing" - The scope *is* the authority.
- **`paged_table` (BaseController).** Returns `{draw, recordsTotal, recordsFiltered, data}`. A few quirks we already know about and intend to keep: `recordsTotal` is the returned *page* count rather than the grand total - Effectively swapped from the DataTables convention; `length == 0` returns *all* rows; and a validation failure returns an empty body rather than a JSON error. These are deliberate, so don't "fix" them.
- **`env()` at runtime.** Every microservice URL/key/credential in these controllers is read with raw `env('...')`, not `config()`. **All of it returns null under `php artisan config:cache`.** We don't cache config today - If that ever changes, provisioning breaks silently. The Environment doc has the full list.
- **The dead suspension check.** `Bouncer` opens with a commented-out `canLogin()` block - `canLogin()` doesn't exist, and `users.suspended_at` is fillable but never read or written. There's no account-suspension path anywhere - Deletion is the only way to deactivate someone. We mention it once here; it applies platform-wide.

---

# Provisioning — BGP (`app/Http/Controllers/Provisioning/`)

Customer discovery and provisioning of BGP peering sessions on router interfaces, delegated to the **BGP provisioning microservice** (`env('bgp_microservice')`, dev default `http://localhost:56000`). `AdminBgpController extends BgpController`.

**Isolation model:** we never expose the microservice's interface handles (`temp_id`, UUIDs). `get_sid_list` caches them in Redis under `U:{userId}:P:{sidHash}` (600s TTL) and hands the client a 128-char sha512 "SID hash", validated by the `SidHash` rule, to echo back on detail/peering/session calls. That per-user key is the **only** live tenant isolation on the detail/peering/session endpoints - Once the hash resolves, we don't re-check that the interface belongs to the caller's org.

> **No authentication to the BGP microservice.** Requests carry only `Content-Type: application/json` - No key, no basic auth, no HMAC. Security rests entirely on the service being localhost-only, and that's worth stating explicitly in any threat model.

## `BgpController` (customer) — prefix `provisioning`, all `auth:api`

### get_sid_list — GET `provisioning/bgp` — `provision.bgp.view`
- **LIVE.** Validates DataTables params via `PagedTable::rules(['community','description','type'])` - The three sort columns are validated but never actually applied. Reads the caller's `gsl` `OrganisationExternalIdentities`, scans each via `helper_scan_cxid` (microservice `GET /scan/cxid/{cxid}`), flattens super-/sub-interfaces (only those with `parent_interface_name === null`), caches each `temp_id` in Redis for 600s, and returns rows keyed by an opaque SID hash.
- **Watch out for:** the row `id` comes from the **positional array index** of the concatenated scan output - Stable only within one list call's TTL window. The `$ids === []` empty-guard is dead code, since a Collection is never `=== []`, but it's harmless. There's no HTTP-status check before we iterate the scan body.

### get_sid_details — GET `provisioning/bgp/{_id}` — `provision.bgp.view`
- **LIVE.** `_id` validated by `SidHash`. Recovers the UUID from Redis - It's a per-user key, so another user's hash won't resolve - Calls `helper_get_details` (`GET /live/details/{uuid}`), reshapes into `{parent, children, bgp}`, and strips `temp_id`. Returns `400 []` if the hash isn't in Redis (expired or foreign) or is invalid.
- **Watch out for:** isolation is *only* the per-user Redis key - No org re-check at detail time.

### get_asn_details — GET `provisioning/peering` — `provision.bgp.view`
- **LIVE.** Inputs `asn_id` (`OwnedAsnIdentity`, owned+approved), `sid` (a free string, forwarded unchecked), and `identity` (`SidHash`). Recovers the UUID from Redis and calls `helper_get_asn` (microservice `GET /peeringdb/asn/{asn}`). Returns `{info: ...}` or `400 []`.

### post_bgp_session — POST `provisioning/session` — `provision.bgp.write`
- **LIVE (with a real bug).** Inputs: `identity` (SidHash), `sid`, `v4_hash`/`v6_hash`, `asn_id` (`OwnedAsnIdentity`), `as_set_id` (nullable `OwnedAsSetIdentity`), `filter` (`full|full-nodefault|default|denyall`), `vlan` (2–4094), `v4_max`/`v6_max`. Rate-limited to **1 request / 300s per user**. Calls `helper_add_session` and maps the microservice `message` onto `200`/`429` (rate-limit)/`500`. All the response bodies are empty - Clients rely on the status codes.
- **Watch out for:** **`as_set_id` is validated but never sent** - The call passes literal `null`, so AS-SET selection is silently discarded (**BUGGY**). A `v4_max`/`v6_max` of 0 passes validation but gets coerced to 1, so we can't actually express 0. It logs the entire request payload at warning level - Noisy and a little sensitive. The `str_contains($message, 'BGP session request accepted')` success test assumes the response always carries a `message` key.

## `AdminBgpController` (staff) — prefix `admin/provisioning`, `auth:api` + `management.*`

### get_full_sid_list — GET `admin/provisioning/bgp/{_type}/{_identifier}` — `provision.bgp.view,management.view`
- **BUGGY.** Meant to scan by `organisation` (resolve gsl identities for the org id) or `billing` (use the identifier directly as a cxid), then behave like `get_sid_list`. **Its validation is entirely dead:** `if($validated === [])` compares a Validator instance to `[]` and never calls `->fails()`, so `type`/`identifier`/paging are **not enforced**. Any `_type` other than `organisation` falls into the billing branch and hands `_identifier` to the microservice unchecked. The only thing saving us here is the `management.view` scope. Contrast the customer `get_sid_list`, which correctly does `!Validator::make(...)->fails()`.

### get_sid_details — GET `admin/provisioning/bgp/{_id}` — `provision.bgp.view,management.view`
- **LIVE.** Byte-identical to the customer method - The override only exists so the admin route resolves here. It still keys Redis under the admin's own user id.

### get_asn_details — GET `admin/provisioning/peering` — `provision.bgp.view,management.view`
- **LIVE.** As the customer method, but `asn_id` uses `AdminAnyAsnIdentity` - Any approved ASN system-wide.

### post_bgp_session — POST `admin/provisioning/session` — `provision.bgp.write,management.write`
- **BUGGY.** As the customer method, with `AdminAny*` ASN/AS-SET rules, but **missing the `429` branch** - `helper_add_session` still rate-limits 1/300s per user, so a throttled response falls through to `500` instead of `429`. Carries the same `as_set_id`-discarded bug.

### get_options — GET `admin/provisioning/option` — `provision.bgp.view,management.view`
- **LIVE.** The only method here that talks to the **billing** microservice (`ENDPOINT_BILLING_MICROSERVICE_URL`, secret passed as a `key` query param). Returns `{billing:[...], organisation:[...]}` - Billing customers (skipping ids `> 1000000`, an unexplained hardcoded threshold) and orgs that hold a `gsl` identity.
- **Watch out for:** there's no success/shape check on the billing response - If it errors or lacks `customer_ids`, iterating it throws an unhandled 500. The secret travels as a plaintext query param.

---

# Provisioning — Compute (dedicated-server BGP)

Same shape as BGP, for dedicated/colocation customers, over the **compute microservice** (`env('compute_microservice_url')`, HTTP Basic auth with `compute_microservice_user`/`password`). Endpoints: `GET /api/peers`, `GET /api/status/peers`, `POST /api/peers`, `DELETE /api/peers/{id}`. `AdminComputeController extends ComputeController`. Delete authorisation is a per-user Redis key `U:{userId}:COMPUTE:ROW:P:{key}` (**72h TTL**) mapping an opaque row key to the real peer id - A customer can only delete peers they personally listed within the last 72h.

## `ComputeController` (customer) — prefix `compute`, `auth:api`

### get_options — GET `compute/options` — `compute.bgp.view`
- **LIVE.** Returns `{v4:bool, v6:bool, asns:[...], as_sets:[...]}` - Whether the tenant has any v4/v6 subtenanted prefix (`Prefixes::getSubtenancyPrefixes`), plus its approved ASNs/AS-SETs. Feeds the add-peer form.

### get_peers — GET `compute/peer` — `compute.bgp.view`
- **BUGGY (paging).** Pages through `GET /api/peers`, keeps peers whose `asn` matches the org's approved ASNs, caches each row key in Redis for 72h, and counts UP sessions against an expected max (2 for v4-only, 4 with v6). **`recordsTotal`/`recordsFiltered` are both just the current page size** - No real total. **Customer-only limit bug:** it passes `skip + length` as the *limit* on each iteration, so the window grows every loop - The admin version correctly passes bare `length`.

### add_peer — POST `compute/peer` — `compute.bgp.write`
- **LIVE.** Validates `asn` (`OwnedAsnIdentity`), `filter_type` (`asn|as-set`), `filter` (an owned ASN or AS-SET), `server_v4` (`SubtenantedAddressV4` - Must sit in a prefix subtenanted to the org, though org 1 passes), `server_v6` (nullable), `table_type` (`full|default`), `import_type` (each option gated by a scope through the `PermittedIn` rule - `HIGHRISK-NOFILTER` needs `compute.bgp.manage`, for instance), and `allow_most_specific`. Derives the edge **node location** from whichever announcement community (`65500:%`/`7578:%` ending `99`) covers `server_v4`, mapped through `CommunityLocationLookup.json` to a subregion name. Then POSTs to the microservice.
- **Watch out for:** we return an opaque `500 {"detail":"Issue provisioning session."}` for *every* failure - No location, microservice reject, whatever - Only the logs tell them apart. There are no null-checks on `GoliathSubregion->first()->name` or the region-string `explode(...)`, so a missing mapping throws a framework 500. The `import_type` scope gating lives in the validation rule, not the route middleware.

### delete_session — DELETE `compute/peer/{_peer_id}` — `compute.bgp.delete`
- **LIVE.** `_peer_id` validated by `ComputePeerIdentity` - The Redis key has to exist. Resolves the real peer id from Redis and hits `DELETE /api/peers/{id}`.
- **Watch out for:** delete authorisation is **entirely** the 72h Redis mapping - If the row was never listed, or the TTL has lapsed, a legitimately-owned peer can't be deleted. The two-step Redis read has a small expiry race that lands on a generic 500.

## `AdminComputeController` (staff) — prefix `admin/compute`, `auth:api` + `management.*`

Mirrors the customer controller with tenant scoping removed. The differences:
- **get_options** (`+management.view`): prefixes come from `Prefixes::where('organisation_id', 1)` - **Hardcoded house org 1**; ASNs/AS-SETs go system-wide.
- **get_peers** (`+management.view`): known ASNs are all approved ones including **soft-deleted** (`withTrashed`), and it passes bare `length` as the limit - The correct form, so it diverges from the customer bug.
- **add_peer** (`+management.write`): `AdminAny*` ASN/AS-SET rules, and **the `SubtenantedAddressV4/V6` checks are dropped** (`server_v4` is bare `required`) - An admin can provision any IP. The community lookup also orders `mask asc` (broadest) against the customer's `desc` (most specific) - An unexplained divergence, and probably unintentional. `peer_name` comes from the ASN's owning org.
- **delete_session** (`+management.write`): re-declared verbatim, and still namespaced to the admin's own user id in Redis - So admin scope does *not* widen the delete surface.

---

# Provisioning — Mercury Tunnels (`TunnelController`)

Customer API for **Mercury v2** GRE tunnels, over the **tunnel microservice** (`env('tunnel_microservice_url')`, `X-API-Key`, base `/api/v1/tunnels`). Routes under `mercury`/`mercury/v2`, `auth:api`. **Loaded only when `APP_ENTERPRISE` is falsey** - We disable Mercury tunnels in enterprise builds. The Redis key `U:{orgId}:TUNNEL:ROW:P:{tunnel_id}` (**7-day TTL**) bridges the synthetic `tunnel_id` to the tunnel's `endpoint_ip`, so edit/update/delete all need `get_tunnels` to have run first. The controller holds a hardcoded 27-entry PoP map (`fra`→Frankfurt, and so on).

### get_options — GET `mercury/option`, `mercury/v2/option` — **`auth:api` only, NO scope**
- **LIVE.** Returns the org's approved ASNs/AS-SETs and the region map for the tunnel-creation form.
- **Watch out for:** the only gate is authentication - There's no `bounce` scope here. Low risk, since it's org-scoped data.

### get_tunnels — GET `mercury/v2/tunnel` — `mercury.v2.view`
- **BUGGY.** Fans out `GET /by-cxid/{cxid}` per `gsl` identity, paginates in memory, resolves `asn_id`/`as_set_id`, computes p2p addresses (v4 via `long2ip`, v6 via a raw Postgres `?::inet + 1`), **skips tunnels whose ASN the org doesn't own**, and caches each raw row in Redis for 7d. **`recordsTotal`/`recordsFiltered` are both the current page size** - Broken totals again. Paging happens *before* the ASN-null filter, so a page can come back with fewer than `length` rows even when more exist. It's N+1 on the HTTP side (one call per cxid) plus two DB queries per row.

### add_tunnel — POST `mercury/v2/tunnel` — `mercury.v2.add`
- **LIVE.** Inputs `address` (public `Cidr`), `bgp_address` (nullable), `asn_id` (`OwnedAsnIdentity`), `as_set_id` (nullable), plus per-region `advanced.endpoint.{code}` CIDRs. Resolves the org's `gsl` cxid (`428` if there isn't one), builds the payload with `max_prefix_len` **hardcoded to 26**, and POSTs. Returns `200`/`409` (exists)/`500`.
- **Watch out for:** in advanced mode it reads `advanced.endpoint[$key]` for *every* region unconditionally - A region that wasn't supplied throws an undefined-index, because validation only requires *present* keys to be CIDRs, not all 27. There's no try/catch around the HTTP call, so a transport error becomes a framework 500.

### update_as_set — PUT `mercury/v2/tunnel/{_tunnel_id}` — `mercury.v2.add`
- **BUGGY.** `_tunnel_id` validated by `OwnedTunnelIdentity` - The Redis key has to exist. The null-AS-SET branch works. **The non-null branch reads `$endpoint['as_set_id']` from the cached Redis row - But `get_tunnels` caches the *raw* microservice object, which has no `as_set_id` key**, so it resolves to `AsSets::find(null)->as_set` and throws a null-property error. It should be reading `$_r->input('as_set_id')`, which is what we actually validated. A prior fix corrected the validation rule and in doing so *exposed* this second bug.
- Note: gated by the `.add` scope, not a dedicated edit scope.

### edit_tunnel — POST `mercury/v2/tunnel/{_tunnel_id}/endpoint` — `mercury.v2.add`
- **BUGGY.** Meant to update per-region endpoint overrides. **Validation key mismatch:** the generated rules use the prefix `endpoint.` (singular) while the submitted data sits under `endpoints` (plural), so the per-region `Cidr` rules never match anything - **Only `tunnel_id` actually gets validated**, and arbitrary `endpoints` content is forwarded to the microservice unvalidated.

### delete_tunnel — DELETE `mercury/v2/tunnel/{_tunnel_id}` — `mercury.v2.delete`
- **LIVE (the one method with a genuine server-side re-auth check).** Recovers `endpoint_ip` from Redis, re-fetches microservice state, and deletes **only if** the org still has a matching `gsl` cxid *and* still owns an approved ASN for the tunnel. Otherwise it's `400 "You do no have access to this tunnel."` - Yes, the typo is in the message itself. This is what defends against a stale Redis row for a tunnel that's since been transferred away.

**Dead in this controller:** `helper_update_prefix_length` is defined but never called. Related debt: `Asns::clean()`'s tunnel teardown is a `// TO-DO` and its DEDI-peer deletion is commented out - We already know about this one, and the consequence is that an ASN org-transfer doesn't currently tear down tunnels/sessions.

---

# Organisations (`app/Http/Controllers/Organisation/`)

The tenant root. `Organisation` (SoftDeletes, fillable `id, name` only) carries nothing itself - Everything hangs off `organisation_id`: users, ASNs, AS-SETs, prefixes, webhooks, product rights, external identities, and per-subsystem attribute rows. We model subtenancy twice: `suborganisations` (the parent/child tree) and `prefix_subtenants` (leasing another org's prefix). Three controllers span three access planes:

- **`Controller`** (self-service) - `auth:api` plus a bare `bounce`, scoped to the caller's own org.
- **`AdminController`** (staff UI) - `auth:api` plus `management.view|write`, cross-tenant, org taken from the URL path and existence-validated only.
- **`AdminApiController`** (internal machine API) - `token:api.internal.organisation` (no auth user), cross-tenant, with writes blocked against the GSL house org **by name lookup**.

> **House-org hardcoding is inconsistent.** The ASN logic uses a literal `organisation_id == 1`, the token API guards by `where name = 'Global Secure Layer'`, and sub-brand parents resolve by name (`'Go Dedicated'`, `'Streamline Servers'`). Every `->first()->id` name lookup fatals if the named row is missing or duplicated. We should standardise on the id the next time this area is touched.

## `Controller` (self-service) — prefix `organisation`

- **get_as_set** / **get_asns** — GET `organisation/asset`, `organisation/asn` — paged lists of the caller's AS-SETs / ASNs.
- **post_as_set** — POST `organisation/asset` — validates `AsSetFormat`, and only succeeds if the nested ASN is an approved owned ASN, then `AsSets::make(..., admin=false)` which creates it with `approved=1`. Slack-notifies at each stage.
- **post_asn** — POST `organisation/asn` — validates `AsnFormat` plus a contact email, and blocks **legacy ASNs** (WHOIS registration year `false` or `<= 2000`) with `422 'Legacy ASN'`. Then `Asns::make(..., admin=false)` verifies the email is in the ASN's WHOIS contacts, creates an unapproved row, mints a `CodeAsnClaim`, and emails an approval link. The forbidden house ASNs `['137409','136510','7578']` can't be claimed.
- **approve_as_set** / **approve_asn** — POST `.../approval` — consume a sha256 claim token, mark the resource approved, and reassign the org. `approve_asn` calls `Asns::clean()` on a genuine org→org transfer, with house org 1 exempt.
- **list_asn_emails** — GET `organisation/asn/email` — returns the ASN's WHOIS contact emails, or `422 'Legacy ASN'`.
- **get_peering** — GET `organisation/asn/{_asn_id}/peer` — `OwnedAsnIdentity`, then external `GET peeringdb.com/api/net?asn=` → `{found, v4_max, v6_max}`.
- **Watch out for (whole controller):** on a **validation failure**, `post_as_set`/`post_asn` never flip their pre-seeded `500_GENERIC`, so bad input comes back as **500, not 400** (**BUGGY**). `approve_as_set` soft-deletes the claim and then uses `->save()` on the trashed row as a truthiness guard - Confusing, and it re-persists a deleted row. There's a silent `catch(Throwable)` throughout. The PeeringDB/WHOIS calls have no timeout, which is deliberate - We already know about it.

## `AdminController` (staff UI) — prefix `admin/organisation`

Cross-tenant, with the org id taken from the path and validated for existence only - A `management.write` operator can act on any org. The highlights:

- **get_organisations** — GET `` — paged `withTrashed()` orgs with correlated counts (users, owned/leased prefixes, children, ASNs, AS-SETs, webhooks) plus agg lists of ASNs/rights. `deleted` toggles trashed-only. Raw Postgres throughout (`string_agg`, `family(address)`).
- **get_organisation** — GET `{id}` — org summary plus counts. **Watch out for:** `Organisation::find(...)->name` is **not null-checked** - There's no `OrganisationIdentity` guard here, unlike its siblings, so a bad or trashed id gives a 500.
- **get_as_set/post_as_set/get_asns/post_asn** — admin-bypass variants (`AsSets::make(..., admin=true)`, `Asns::make(..., admin=true)`) that create/approve without the ownership checks. `get_asns` treats `_organisation_id == 0` as an "all *other* orgs" pseudo-view with enriched labels.
- **get_parentless_organisation / get_suborganisations / get_users** — paged supporting lists.
- **post_organisation** — POST `` and `{id}/child` — creates an org (plus an optional suborg link) and its initial user (empty password, `sendSetupEmail()`). **Watch out for:** `Rule::notIn(all user emails)` loads every user email into memory on each request.
- **post_existing_suborganisation** — PATCH `{id}/child` — links an existing child. **DEAD field:** the response `{email_token: null}` is always null, because the transaction closure has no return. No dedup or cycle check on the parent/child link either.
- **delete_organisation** — DELETE `{id}` — `OrganisationIdentity(withTrashed)`, logs a summary, then deletes subtenancies/prefixes/ASNs/AS-SETs/child-side suborg rows/webhooks/org and **anonymises** the users (email mangled, `password='Automated Removal'`, soft-deleted). **Watch out for:** it leaves residue - `suborganisations` rows where the org is the **child** aren't deleted, and `supernets` get counted but never deleted.
- **External identities & product rights:** `get_external_attributes`/`post_external_identity` (a full-replace of `organisation_external_identities`, with keys constrained to `gsl|whmcs_godedicated|whmcs_streamline` by the `ExternalApplication` rule), and `get_rights`/`put_rights` (a full-replace of `product_rights`, names constrained by `UsageRight`). **Watch out for:** `put_rights` has a silent `catch` **with no `DB::rollback()`** - An exception mid-replace leaves an open transaction and relies on connection teardown to clean up.

## `AdminApiController` (internal token API) — prefix `internal`/`internal/v2`

Machine caller (`token:api.internal.organisation`, no auth user) - This is what WHMCS and the microservices call to provision and sync orgs. Writes are blocked against GSL by name. The key methods:

- **get_external_identities** — GET `internal/organisation` — reverse lookup `external id → Creatia org id` for a given `application`, used by WHMCS and the microservices.
- **create_organisation** — POST `internal/organisation` — creates an org (name suffixed by sub-brand), rejects if *any* supplied external identifier already exists anywhere, seeds the Mercury/Goliath attribute rows, and links a sub-brand parent **by name**. Returns the new org id.
- **create_user / create_user_v2** — create a user in an org (group `Creatia Standard User`, or read-only vs standard for v2) via `sendSetupEmail()`. **Watch out for:** we don't enforce email uniqueness here, unlike the admin UI create, and the group is resolved by name, so it fatals if that group is missing.
- **update_organisation / update_organisation_v2** — upsert (v1: one identity per key) or add/remove (v2: multiple identities per key, where `add` doesn't dedup, so you can get duplicate rows).
- **update_mercury** — sets `aggregate_bandwidth` from the input key `bandwidth` - The name mismatch is intentional but easy to misread.
- **add_product_right / remove_product_right / fetch_product_right** — manage `product_rights`. `add_product_right` does **not** constrain the name to `UsageRight` - It accepts any string, which is an asymmetry with the admin UI's `put_rights` - And granting `dedi-*` elevates the org's read-only users to standard. `remove_product_right`'s `mercury` case calls the **tunnel microservice** (`DELETE /api/v1/tunnels/by-cxid/{cxid}`) to tear down tunnels. **Watch out for:** that's a direct `env('tunnel_microservice_*')` call, so it breaks under `config:cache`, has no timeout, and its failures surface only via a Slack status-code report.

## `HookController` (webhooks) — brief

Prefix `hook`, `bounce:hook.view`/`hook.update`. Manages per-org reporting webhooks (`Webhook`): list; create (the URL is stored **symmetrically encrypted** with `creatia.reporting.encryption_key`, `hook_type ∈ slack|discord|google|raw`, validated by `SafeDomain`); delete (`OwnedWebhookIdentity`); and `test_hook` (rate-limited 6/attempt, enqueues a sample `WebhookMessage`). `get_hook_messages` is an **empty stub** - No body, no route. The automation docs cover how `hook:report-send` consumes these.

---

# Groups / Permissions — RBAC (`app/Http/Controllers/Permission/`)

The role system behind `bounce`. The chain is `users.group_id` → `groups` → `group_permissions`. Each `group_permissions` row is one scope with four columns: `permission` (the scope string), `conditional` (an optional product-right gate), `identifier` (an optional org-external-identity gate), and `status` (has to be `1` to count). On the wire, scopes travel as a single space-joined string (`permission[:conditional][?identifier][=status]`), built with a Postgres `string_agg` and parsed back apart on write. `User::getPermissions()` filters the rows by the org's product rights and external identities, and `canDo()` requires all requested scopes. System group ids are seeded in `config/security.php` (`group_admin_id=1` … `group_manager_id=5`) - **No controller here protects them**.

## `Permission/Controller.php` (aliased `GroupController`)
- **get_groups** — **UNROUTED.** Imported in the routes file, but no route actually references `GroupController::class`. It's a `user_visible`-filtered group list, and looks to have been superseded by the admin version. Latent trap: anyone who wires it up gets no `bounce` scope by default.

## `Permission/AdminController.php` — prefix `admin/group`
- **get_group** — GET `admin/group/{_group_id}` — `management.view` — returns `{name, scopes:[...]}` from a single `string_agg` query. **BUGGY:** the `if(!$group)` null-check is mis-ordered - `->first()->toArray()` runs *first*, so a nonexistent id **fatals** before we ever reach the 500 branch, and there's no try/catch here to catch it. A group with zero permissions yields `explode(' ', null)` → `['']`.
- **get_groups** — GET `admin/group` — `management.view` — paged list of all groups, with no `user_visible` filter. `scopes` comes back as the raw space-joined string, not exploded, unlike `get_group`.
- **put_group** — POST `admin/group` (create) / PATCH `admin/group/{_group_id}` (edit) — `management.write` — find-or-new the group, then **delete-all-then-reinsert** its `group_permissions` from the payload (replace-all semantics), inside a transaction. **Watch out for:** editing a nonexistent id runs `null->fill()`, which gets caught and turns into a silent generic 500 - There's no `GroupIdentity` guard here, unlike delete. Creating a **scopeless** group is impossible, since `foreach(null)` throws a 500. The validation regex and the parsing regex disagree with each other. `user_visible` is never set. And there's no protection of system groups 1–5.
- **delete_group** — **UNROUTED** - No `DELETE admin/group/{id}` route exists. It would reassign members to a `reassign` group and then delete. **Watch out for (if it's ever routed):** the `// TO DO - only allow if nobody in the group` guard was never built, so reassign-on-delete is unconditional; the error message says "user" not "group"; there's a silent catch; and again no protection of system groups.

## How `canDo`/`bounce` consume this (recap)
`getPermissions()` returns `[]` if `group_id` is null; otherwise it returns the enabled (`status=1`), product-right-satisfied, identity-permitted `permission` strings. `canDo($scopes)` requires all of them, and an empty scope list degrades `bounce` to auth-only. Bouncer enforces it per route.

---

# Users (`app/Http/Controllers/User/`)

A user belongs to one org and one group. Provisioning is passwordless-by-creation - New users are saved with `password = ''` (listings compute `is_setup = CASE WHEN password = '' THEN 0 ELSE 1`), and a setup email drives the first-password setup (`sendSetupEmail()` → queued `SendAuthEmail` job → `UserInvitation` mail, with a plaintext `signup_token`). Reset uses `sendResetEmail()` (sha256 token, 2h) over in the auth controller. The auth docs have the token mechanics.

## `Controller` (self-service) — prefix `user`, `auth:api` + `bounce:organisation.manage`

Everything here is scoped to the caller's own org, via `whereOwned` / `OwnedUserIdentity`, and needs the single `organisation.manage` scope.

- **get_users** — GET `user` — paged users for the caller's org.
- **get_user** — GET `user/{_user_id}` — **appears UNROUTED** for self-service, since only list/create/update/delete are wired. Tenant-scoped, and **not-found returns 500, not 404**.
- **create_user** — POST `user` — validates `name` plus `email` (`rfc,dns`, global-unique). **The new user is cloned into the creator's own `group_id` and org** - No group input is honoured. Slack-notifies and sends the setup email. **Watch out for:** every self-created user inherits the creator's full permission set - Including `organisation.manage` - So a customer admin can mint equal-privilege accounts, though not *higher* ones, since the group isn't selectable. A duplicate email returns `400` against a `200` on success, which is **email enumeration** across the whole `users` table. `unique:users,email` ignores soft-deletes.
- **update_user** — PUT `user/{_user_id}` — `OwnedUserIdentity` plus `whereOwned`; updates only `name`/`email`, and can't change group or org, so there's no escalation path.
- **delete_user** — DELETE `user/{_user_id}` — `OwnedUserIdentity`; mangles the email to free it for reuse, sets `password='Automated Removal'`, then soft-deletes, inside a transaction. **Watch out for:** there's **no self-delete guard**, so a customer can delete their own account, and it returns `200` even if the transaction rolled back - Success isn't actually tied to the deletion.

## `AdminController` (staff) — prefix `admin/user`

Cross-tenant, with **no per-org authorization** - Any `management.*` holder operates across every tenant. View methods need `management.view`, writes need `management.write`.

- **get_users / get_user** — cross-tenant list / fetch (with `organisation_name`/`organisation_id`). Not-found gives a 500.
- **create_user** — POST `admin/user` — validates `name`, `email` (`rfc,dns`, global-unique), `group_id` (`GroupIdentity`), and `organisation_id` (`OrganisationIdentity`). Uses the **request-supplied** group and org. **Watch out for (principal escalation surface):** `GroupIdentity`/`OrganisationIdentity` only check that the row *exists* - We don't check that the group isn't a privileged/admin one, or that the org is actually one the caller manages. A `management.write` holder can create a user in **any** org and drop them in **any** group, including the most privileged.
- **update_user** — PUT `admin/user/{_user_id}` — `AdminAnyUserIdentity` plus `group_id`/`organisation_id`; **can relocate any user into any org and any group** - Same existence-only weakness, so the same promotion path. Email validation here is plain `email`, with no `rfc,dns` - Inconsistent with create.
- **delete_user** — DELETE `admin/user/{_user_id}` — `AdminAnyUserIdentity`; **has a self-delete guard** (`!= Auth id`) - The very protection the self-service path is missing. **Watch out for:** it does **not** mangle the email, so the soft-deleted row keeps its original address, and since `unique:users,email` on create ignores soft-deletes, that email is **permanently reserved** until someone hard-deletes the row - Inconsistent with self-service, and a potential lockout. The `catch(Throwable)` is completely empty, so no logging.

## Self-service vs admin, at a glance

| Concern | Self-service | Admin |
| - | - | - |
| Tenant scope | Own org (`whereOwned`) | None — global |
| Group/org on create | Forced to creator's | Caller-supplied, any existing |
| Change group/org on edit | No | Yes (any) |
| Self-delete | Allowed (no guard) | Blocked |
| Delete hygiene | Mangles email+password | Soft-delete only; email stays reserved |
| Email validation | `rfc,dns` | create `rfc,dns`, update plain |

**Top security items to weigh** (documented, not fixed): admin create/update place users in any org/group on existence-only checks (cross-tenant plus privilege escalation); self-service create clones the creator's group; email enumeration on customer create; admin delete's email-reservation collision; not-found → 500 in both `get_user`s; self-service delete has no self-delete guard and returns 200 on a rollback; and the platform-wide dead suspension path.
