cast must be team-aware: assert the token's team before mutating (fail-closed) #9

Closed
opened 2026-07-12 20:39:10 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-12 20:39:10 +00:00 (Migrated from github.com)

The fact

Coolify API tokens are team-scoped, and nothing below the team scopes them.

Verified against coollabsio/coolify v4.1.2:

  • User::createToken overrides Sanctum's and stamps the token with the session's team:
    'team_id' => session('currentTeam')->id,
    
  • The API resolves resources through the team on the token:
    $teamId   = getTeamIdFromToken();
    $resource = getResourceByUuid($uuid, $teamId);
    
  • getResourceByUuid walks resource → environment → project → team_id and returns null when it
    doesn't match.
  • Environment has no team_id of its own (it belongs to a project). No API path scopes by
    environment. Coolify environments are an organizational construct, not an auth boundary.
  • A team owns its servers, projects, private keys, S3 storages and GitHub Apps
    (Team hasMany ...). A server belongs to exactly one team — no pivot, and unlike GithubApp
    there is no is_system_wide escape hatch. Upstream confirms teams cannot share a server and defers
    it to v5: coollabsio/coolify#1820, coollabsio/coolify#3235.

Why this is a correctness bug, not a hardening nice-to-have

A wrong-team token does not error. getResourceByUuid just returns null.

To cast apply, "null" is indistinguishable from "this resource does not exist yet" — which is an
invitation to create it. So an apply run with a token minted under the wrong team will not fail
loudly; it will happily provision a duplicate set of resources into the wrong team, against
whatever server that team happens to own. That is the worst shape of failure: silent, mutating, and
discovered late.

Today this is masked because there is exactly one team and one token. It stops being masked the moment
staging joins the control plane (incubator prod-migration Task 13).

Asks

  1. Team in the state modelenvironments.yaml carries the expected team (name and/or id) per
    environment.
  2. Fail-closed pre-flight assert — before any mutation, resolve the team the token actually
    belongs to and compare it against the manifest's expected team. Abort on mismatch, loudly. This is
    the whole point of the issue: turn a silent mis-target into a refusal.
  3. cast server add registers under the token's team, so it inherits the same check.
  4. Investigate GithubApp.is_system_wide as the supported way to let one App serve every team —
    it may make per-team App duplication unnecessary (and may make #6 moot; see below).
  • #6 (github_apps keyed by full org/repo slug) may be moot: under the consuming project's
    three-tier promotion, staging now clones upstream — the same repo and org as prod — so a single
    App can plausibly serve both. Worth confirming before building the full-slug keying.
  • Adjacent, and explicitly not a security control: a serialization guardrail (one provisioning
    run at a time). That is a correctness guarantee — it stops two concurrent applies clobbering each
    other's Coolify state. Worth having on its own merits; separate issue.
## The fact Coolify API tokens are **team-scoped**, and nothing below the team scopes them. Verified against `coollabsio/coolify` v4.1.2: - `User::createToken` overrides Sanctum's and stamps the token with the session's team: ```php 'team_id' => session('currentTeam')->id, ``` - The API resolves resources through the team on the token: ```php $teamId = getTeamIdFromToken(); $resource = getResourceByUuid($uuid, $teamId); ``` - `getResourceByUuid` walks `resource → environment → project → team_id` and **returns `null`** when it doesn't match. - `Environment` has **no `team_id`** of its own (it belongs to a project). No API path scopes by environment. **Coolify environments are an organizational construct, not an auth boundary.** - A team owns its **servers, projects, private keys, S3 storages and GitHub Apps** (`Team hasMany ...`). A **server belongs to exactly one team** — no pivot, and unlike `GithubApp` there is no `is_system_wide` escape hatch. Upstream confirms teams cannot share a server and defers it to v5: coollabsio/coolify#1820, coollabsio/coolify#3235. ## Why this is a correctness bug, not a hardening nice-to-have A wrong-team token **does not error**. `getResourceByUuid` just returns `null`. To `cast apply`, "null" is indistinguishable from "this resource does not exist yet" — which is an invitation to **create it**. So an apply run with a token minted under the wrong team will not fail loudly; it will happily **provision a duplicate set of resources into the wrong team**, against whatever server that team happens to own. That is the worst shape of failure: silent, mutating, and discovered late. Today this is masked because there is exactly one team and one token. It stops being masked the moment staging joins the control plane (incubator prod-migration Task 13). ## Asks 1. **Team in the state model** — `environments.yaml` carries the expected team (name and/or id) per environment. 2. **Fail-closed pre-flight assert** — before *any* mutation, resolve the team the token actually belongs to and compare it against the manifest's expected team. Abort on mismatch, loudly. This is the whole point of the issue: turn a silent mis-target into a refusal. 3. **`cast server add`** registers under the token's team, so it inherits the same check. 4. **Investigate `GithubApp.is_system_wide`** as the supported way to let one App serve every team — it may make per-team App duplication unnecessary (and may make #6 moot; see below). ## Related - **#6** (`github_apps` keyed by full org/repo slug) may be **moot**: under the consuming project's three-tier promotion, staging now clones **upstream** — the same repo and org as prod — so a single App can plausibly serve both. Worth confirming before building the full-slug keying. - Adjacent, and explicitly **not** a security control: a **serialization guardrail** (one provisioning run at a time). That is a *correctness* guarantee — it stops two concurrent applies clobbering each other's Coolify state. Worth having on its own merits; separate issue.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#9
No description provided.