cast is team-aware: assert the token's team before mutating (fail-closed) #10

Merged
dan-claude-bot merged 1 commit from feat/team-aware-assert into main 2026-07-13 13:00:02 +00:00
dan-claude-bot commented 2026-07-12 20:55:32 +00:00 (Migrated from github.com)

Closes #9.

The bug

A wrong-team Coolify token does not error. Tokens are team-scoped, and the API resolves what a token cannot see to nullgetResourceByUuid walks resource → environment → project → team_id and returns null on a mismatch.

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 would not fail loudly; it would happily provision a duplicate set of resources into the wrong team, against whatever server that team owns. Silent, mutating, discovered late. Correctness bug, not hardening.

Masked today only because there is one team and one token. It stops being masked the moment staging joins the control plane.

What this does

1. Team in the state modelenvironments.yaml carries a required team: per environment (id, name, or both; both compared when both given):

environments:
  prod:
    server: prod-box
    team: { id: 1, name: heavy-duty }

Required is the whole point. An environment with no declared team is one cast cannot verify it is pointed at, and an unverifiable target is exactly the failure the binding exists to prevent. No team, no apply — enforced at the schema.

2. Fail-closed pre-flight assert — every command that reaches a live Coolify (apply, diff, server add, smoke) resolves GET /teams/current (the only endpoint that answers "what team does this token act as?"TeamController@current_teamgetTeamIdFromToken(), resolved from the token itself, not a session) and aborts on mismatch.

It gates the first read, not merely the first write. An unasserted diff against the wrong team reports "everything is absent" — which is precisely the lie an apply would then act on. An unreadable or unauthorized answer aborts too: that is not "no team", it is an unknown answer to the one question cast must not guess at.

3. server add inherits the check — it now takes --env. A server belongs to exactly one team forever (no pivot, and unlike GithubApp no is_system_wide escape hatch); registering it under the wrong team is not a mistake you fix with a PATCH. smoke takes --env for the same reason — it POSTs env vars onto a live app, which is a mutation.

4. New read-only cast team — prints the team the current token acts as, so the binding can be filled in without a chicken-and-egg (it deliberately does not require a team binding; asserting there would be circular). With --env it also checks the binding, making it the dry run for "would apply refuse?" — asked without touching anything.

Ask #4: is_system_wide — investigated, and it works

is_system_wide IS the supported way to let one GitHub App serve every team, on both the read and write side (GithubController @ v4.1.2):

// list_github_apps (backs GET /github-apps)
$githubApps = GithubApp::where(function ($query) use ($teamId) {
    $query->where('team_id', $teamId)
        ->orWhere('is_system_wide', true);

POST /github-apps validates and accepts is_system_wide (boolean). So per-team App duplication is unnecessary — which supports #6 being moot, and means #7 (cast github-app create) has a real API to build on.

Corollary worth its own line, now in docs/semantics.md: because GET /github-apps deliberately includes other teams' system-wide Apps, resolving a GitHub App by name is not a proxy for being in the right team. That is the second reason the team assert has to be explicit.

Verification

Beyond 86 passing unit tests: the real CLI binary was driven over real HTTP against a fake Coolify that maps tokens to teams the way the real one does. With a wrong-team token, team --env, smoke --env and server add --env all refuse — and the request log confirms the only call that ever reached the wire was GET /teams/current. No key upload, no server registration, no env write escaped the gate.

An adversarial review of the diff caught two real defects, both fixed here and covered by tests:

  • Team id 0 is the Root Team — the team a single-admin instance keeps everything in (if ($user->id === 0) { $team['id'] = 0; $team['name'] = 'Root Team'; }, app/Models/User.php @ v4.1.2). The schema originally used .positive(), which would have rejected the id check on exactly the topology that most needs it. Now .nonnegative(), and 0 is compared as a real value rather than a falsy absent.
  • assertTeam failed open on an empty expectation{} compared nothing and passed against any team. Unreachable through the schema, but this is the one function whose entire job is to fail closed, and it must not depend on a .refine() in another file to do so. It now refuses on its own.

⚠️ Breaking — state repo and runbook

  • environments.yaml must gain a team: for every environment. Until it does, cast refuses (loudly, by design). Run cast team to get the values.
  • Runbook call sites need --env: cast server add … and cast smoke (prod-migration Task 8 steps 2 + 8, Task 13 step 2).
Closes #9. ## The bug A wrong-team Coolify token **does not error**. Tokens are team-scoped, and the API resolves what a token cannot see to `null` — `getResourceByUuid` walks `resource → environment → project → team_id` and returns `null` on a mismatch. 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 would not fail loudly; it would happily **provision a duplicate set of resources into the wrong team**, against whatever server that team owns. Silent, mutating, discovered late. Correctness bug, not hardening. Masked today only because there is one team and one token. It stops being masked the moment staging joins the control plane. ## What this does **1. Team in the state model** — `environments.yaml` carries a **required** `team:` per environment (`id`, `name`, or both; both compared when both given): ```yaml environments: prod: server: prod-box team: { id: 1, name: heavy-duty } ``` Required is the whole point. An environment with no declared team is one cast *cannot verify it is pointed at*, and an unverifiable target is exactly the failure the binding exists to prevent. No team, no apply — enforced at the schema. **2. Fail-closed pre-flight assert** — every command that reaches a live Coolify (`apply`, `diff`, `server add`, `smoke`) resolves `GET /teams/current` (the only endpoint that answers *"what team does this token act as?"* — `TeamController@current_team` → `getTeamIdFromToken()`, resolved from the token itself, not a session) and aborts on mismatch. It gates the first **read**, not merely the first write. An unasserted `diff` against the wrong team reports *"everything is absent"* — which is precisely the lie an `apply` would then act on. An unreadable or unauthorized answer aborts too: that is not "no team", it is an unknown answer to the one question cast must not guess at. **3. `server add` inherits the check** — it now takes `--env`. A server belongs to **exactly one team forever** (no pivot, and unlike `GithubApp` no `is_system_wide` escape hatch); registering it under the wrong team is not a mistake you fix with a `PATCH`. `smoke` takes `--env` for the same reason — it POSTs env vars onto a live app, which is a mutation. **4. New read-only `cast team`** — prints the team the current token acts as, so the binding can be filled in without a chicken-and-egg (it deliberately does *not* require a team binding; asserting there would be circular). With `--env` it also checks the binding, making it the dry run for *"would apply refuse?"* — asked without touching anything. ## Ask #4: `is_system_wide` — investigated, and it works **`is_system_wide` IS the supported way to let one GitHub App serve every team**, on both the read and write side (`GithubController` @ v4.1.2): ```php // list_github_apps (backs GET /github-apps) $githubApps = GithubApp::where(function ($query) use ($teamId) { $query->where('team_id', $teamId) ->orWhere('is_system_wide', true); ``` `POST /github-apps` validates and accepts `is_system_wide` (boolean). So **per-team App duplication is unnecessary** — which supports #6 being moot, and means #7 (`cast github-app create`) has a real API to build on. **Corollary worth its own line, now in `docs/semantics.md`:** because `GET /github-apps` deliberately includes *other* teams' system-wide Apps, **resolving a GitHub App by name is not a proxy for being in the right team.** That is the second reason the team assert has to be explicit. ## Verification Beyond 86 passing unit tests: the real CLI binary was driven over real HTTP against a fake Coolify that maps tokens to teams the way the real one does. With a wrong-team token, `team --env`, `smoke --env` and `server add --env` all refuse — and the request log confirms **the only call that ever reached the wire was `GET /teams/current`**. No key upload, no server registration, no env write escaped the gate. An adversarial review of the diff caught two real defects, both fixed here and covered by tests: - **Team id `0` is the Root Team** — the team a single-admin instance keeps everything in (`if ($user->id === 0) { $team['id'] = 0; $team['name'] = 'Root Team'; }`, `app/Models/User.php` @ v4.1.2). The schema originally used `.positive()`, which would have rejected the id check on exactly the topology that most needs it. Now `.nonnegative()`, and `0` is compared as a real value rather than a falsy absent. - **`assertTeam` failed open on an empty expectation** — `{}` compared nothing and passed against any team. Unreachable through the schema, but this is the one function whose entire job is to fail closed, and it must not depend on a `.refine()` in another file to do so. It now refuses on its own. ## ⚠️ Breaking — state repo and runbook - **`environments.yaml` must gain a `team:` for every environment.** Until it does, cast refuses (loudly, by design). Run `cast team` to get the values. - **Runbook call sites need `--env`:** `cast server add …` and `cast smoke` (prod-migration Task 8 steps 2 + 8, Task 13 step 2).
Sign in to join this conversation.
No reviewers
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#10
No description provided.