cast is team-aware: assert the token's team before mutating (fail-closed) #10
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#10
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/team-aware-assert"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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—getResourceByUuidwalksresource → environment → project → team_idand returnsnullon a mismatch.To
cast apply,nullis 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.yamlcarries a requiredteam:per environment (id,name, or both; both compared when both given):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) resolvesGET /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
diffagainst the wrong team reports "everything is absent" — which is precisely the lie anapplywould 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 addinherits the check — it now takes--env. A server belongs to exactly one team forever (no pivot, and unlikeGithubAppnois_system_wideescape hatch); registering it under the wrong team is not a mistake you fix with aPATCH.smoketakes--envfor 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--envit 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 worksis_system_wideIS the supported way to let one GitHub App serve every team, on both the read and write side (GithubController@ v4.1.2):POST /github-appsvalidates and acceptsis_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: becauseGET /github-appsdeliberately 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 --envandserver add --envall refuse — and the request log confirms the only call that ever reached the wire wasGET /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:
0is 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(), and0is compared as a real value rather than a falsy absent.assertTeamfailed 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.yamlmust gain ateam:for every environment. Until it does, cast refuses (loudly, by design). Runcast teamto get the values.--env:cast server add …andcast smoke(prod-migration Task 8 steps 2 + 8, Task 13 step 2).