Skip to content

UpdateProject RPC silently re-parents projects across orgs, leaving SpiceDB out of sync #1584

@AmanGIT07

Description

@AmanGIT07

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

  1. 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.
  2. 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.
  3. As the caller, invoke UpdateProject with id = proj-X and body.org_id = org-B (other body fields can be unchanged).
  4. 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 :289UPDATE 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

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions