Summary
UpdateProject accepts any existing org_id in the request body and writes it to Postgres without validating the caller's authority over the destination org and without updating the corresponding SpiceDB tuple. The result
is a half-moved project: Postgres says it belongs to the new org, but the authorization graph still treats it as belonging to the old one.
Steps to reproduce
- Create two organizations,
org-A and org-B. The caller is a member/admin of org-A only and has no relationship with org-B.
- Create a project
proj-X under org-A. Verify in Postgres that projects.org_id = org-A and in SpiceDB that the tuple project:proj-X#organization@organization:org-A exists.
- As the caller, invoke
UpdateProject with id = proj-X and body.org_id = org-B (other body fields can be unchanged).
- Observe the response: the call succeeds (HTTP 200) and the returned project shows
org_id = org-B.
Actual behavior
projects.org_id in Postgres is now org-B.
- The SpiceDB tuple
project:proj-X#organization@organization:org-A is unchanged — the new tuple pointing to org-B is never written.
- Members of
org-A can still access proj-X via the SpiceDB graph; members of org-B cannot, despite Postgres listing the project under their org.
- The caller successfully moved a project into an org they have no permission on.
Expected behavior
- The org of an existing project is immutable via
UpdateProject. The call should either ignore body.org_id or reject the request when it differs from the current org.
Where it happens
- Handler:
internal/api/v1beta1connect/project.go:104 — passes request.Msg.GetBody().GetOrgId() straight into project.Service.Update with no validation.
- Service:
core/project/service.go:242 — pass-through to repository.UpdateByID / UpdateByName.
- Repository:
internal/store/postgres/project_repository.go:241 and :289 — UPDATE projects SET org_id = $newOrgID runs on every call. The only constraint is the FK on organizations(id), so any existing, non-deleted
org is accepted.
Why this is broken
-
SpiceDB stays out of sync. On project creation, addProjectToOrg (core/project/service.go:337) writes the tuple project:X#organization@organization:OLD. Update writes nothing to SpiceDB. After an org change,
Postgres points at the new org but the SpiceDB graph still inherits permissions from the old org. Members of the old org keep access; members of the new org get none.
-
No authorization check on the target org. The handler does not verify the caller has any permission on the destination org. Combined with (1), any user with update on the project can re-parent it under an org they
have no relationship with.
-
No "did org actually change?" branch. org_id is written on every update call regardless of whether it changed, so the request shape itself doesn't gate the issue.
Why moving an org should not be part of UpdateProject
Re-parenting a project is not a metadata edit — it changes the authorization boundary, the billing owner, the audit ownership, and every inherited role assignment. Folding it into a generic Update invites exactly the kind
of silent, half-applied state we have today: a single SQL write that diverges from the SpiceDB graph and the policy table. A move is a multi-step, authorized transaction; an update is a metadata patch. They should not share a
path.
Direction for the fix
The fix belongs in core/project/service.go::Update, — domain invariants should be enforced at the service layer so future callers (other RPCs, admin tooling, internal jobs) get the guarantee for free.
For now, Update should treat the project's org as immutable: either ignore the inbound org_id entirely and keep the project's existing value, or validate that the inbound value matches the existing one and reject
otherwise. Either approach closes the bug without introducing new surface area.
Future enhancement (if truly required)
A dedicated MoveProject RPC that authorizes the caller on both source and destination orgs, updates Postgres, rewrites the SpiceDB project#organization tuple, and emits a distinct audit event — all transactionally.
Summary
UpdateProjectaccepts any existingorg_idin the request body and writes it to Postgres without validating the caller's authority over the destination org and without updating the corresponding SpiceDB tuple. The resultis a half-moved project: Postgres says it belongs to the new org, but the authorization graph still treats it as belonging to the old one.
Steps to reproduce
org-Aandorg-B. The caller is a member/admin oforg-Aonly and has no relationship withorg-B.proj-Xunderorg-A. Verify in Postgres thatprojects.org_id = org-Aand in SpiceDB that the tupleproject:proj-X#organization@organization:org-Aexists.UpdateProjectwithid = proj-Xandbody.org_id = org-B(other body fields can be unchanged).org_id = org-B.Actual behavior
projects.org_idin Postgres is noworg-B.project:proj-X#organization@organization:org-Ais unchanged — the new tuple pointing toorg-Bis never written.org-Acan still accessproj-Xvia the SpiceDB graph; members oforg-Bcannot, despite Postgres listing the project under their org.Expected behavior
UpdateProject. The call should either ignorebody.org_idor reject the request when it differs from the current org.Where it happens
internal/api/v1beta1connect/project.go:104— passesrequest.Msg.GetBody().GetOrgId()straight intoproject.Service.Updatewith no validation.core/project/service.go:242— pass-through torepository.UpdateByID/UpdateByName.internal/store/postgres/project_repository.go:241and:289—UPDATE projects SET org_id = $newOrgIDruns on every call. The only constraint is the FK onorganizations(id), so any existing, non-deletedorg is accepted.
Why this is broken
SpiceDB stays out of sync. On project creation,
addProjectToOrg(core/project/service.go:337) writes the tupleproject:X#organization@organization:OLD.Updatewrites nothing to SpiceDB. After an org change,Postgres points at the new org but the SpiceDB graph still inherits permissions from the old org. Members of the old org keep access; members of the new org get none.
No authorization check on the target org. The handler does not verify the caller has any permission on the destination org. Combined with (1), any user with
updateon the project can re-parent it under an org theyhave no relationship with.
No "did org actually change?" branch.
org_idis written on every update call regardless of whether it changed, so the request shape itself doesn't gate the issue.Why moving an org should not be part of
UpdateProjectRe-parenting a project is not a metadata edit — it changes the authorization boundary, the billing owner, the audit ownership, and every inherited role assignment. Folding it into a generic
Updateinvites exactly the kindof silent, half-applied state we have today: a single SQL write that diverges from the SpiceDB graph and the policy table. A move is a multi-step, authorized transaction; an update is a metadata patch. They should not share a
path.
Direction for the fix
The fix belongs in
core/project/service.go::Update, — domain invariants should be enforced at the service layer so future callers (other RPCs, admin tooling, internal jobs) get the guarantee for free.For now,
Updateshould treat the project's org as immutable: either ignore the inboundorg_identirely and keep the project's existing value, or validate that the inbound value matches the existing one and rejectotherwise. Either approach closes the bug without introducing new surface area.
Future enhancement (if truly required)
A dedicated
MoveProjectRPC that authorizes the caller on both source and destination orgs, updates Postgres, rewrites the SpiceDBproject#organizationtuple, and emits a distinct audit event — all transactionally.