🚑 Revoke file access when a member leaves the team (#12097)

Delete the team-scoped file, project and pin relations on leave-team
and delete-team-member, require a live team membership in the file,
project, search and listing permission queries, and clean
pre-existing orphan rows with a migration. Former members now get
not-found on file actions instead of keeping read and rename access
(GHSA-v9r9-h77c-55m2).

AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
Andrey Antukh 2026-10-05 16:47:07 +02:00 committed by GitHub
parent cf46b53bcb
commit ab58922f2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 404 additions and 11 deletions

View File

@ -15,6 +15,7 @@
- `app.rpc.permissions` provides predicate/check factories. Failed permission checks intentionally raise `:not-found` / `:object-not-found`, not an authorization-specific error, to avoid leaking object existence.
- Team role flags are normalized as owner > admin > editor > viewer. Owner/admin imply edit; any membership row implies read.
- File/project role rows only count with a live team membership: the file/project permission queries require a matching `team_profile_rel` row, and leaving or removing a member deletes their team-scoped `file`/`project`/`team-project-profile-rel` rows (GHSA-v9r9-h77c-55m2).
- File/project/comment checks are implemented in the owning command namespaces, often via helpers imported from `files`, `teams`, or `projects`; do not bypass those helpers with direct DB lookups unless preserving their not-found semantics.
- Comment permission includes both logged-in state and the file/team comment policy. Shared viewer paths may pass `share-id`; preserve that path when changing comment queries.

View File

@ -344,8 +344,13 @@
fpr.can_edit
from file_profile_rel as fpr
inner join file as f on (f.id = fpr.file_id)
inner join project as p on (p.id = f.project_id)
where fpr.file_id = ?
and fpr.profile_id = ?
and exists (select 1
from team_profile_rel as tpr
where tpr.team_id = p.team_id
and tpr.profile_id = fpr.profile_id)
union all
select tpr.is_owner,
tpr.is_admin,
@ -361,8 +366,13 @@
ppr.can_edit
from project_profile_rel as ppr
inner join file as f on (f.project_id = ppr.project_id)
inner join project as p on (p.id = ppr.project_id)
where f.id = ?
and ppr.profile_id = ?")
and ppr.profile_id = ?
and exists (select 1
from team_profile_rel as tpr
where tpr.team_id = p.team_id
and tpr.profile_id = ppr.profile_id)")
(defn- get-file-permissions*
[conn profile-id file-id]

View File

@ -499,7 +499,10 @@
:fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")}
{:name "0152-rename-version-and-add-indexes-to-server-error-report"
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}
{:name "0153-del-orphan-profile-rels-after-team-leave"
:fn (mg/resource "app/migrations/sql/0153-del-orphan-profile-rels-after-team-leave.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -0,0 +1,27 @@
-- Remove team-scoped role rows whose profile is no longer a member of
-- the team that owns the project/file. Leftover from before
-- leave-team/delete-team-member cleaned them up (GHSA-v9r9-h77c-55m2).
-- Rows with a live team membership are kept.
DELETE FROM file_profile_rel AS fpr
USING file AS f
INNER JOIN project AS p ON (p.id = f.project_id)
WHERE fpr.file_id = f.id
AND NOT EXISTS (SELECT 1
FROM team_profile_rel AS tpr
WHERE tpr.team_id = p.team_id
AND tpr.profile_id = fpr.profile_id);
DELETE FROM project_profile_rel AS ppr
USING project AS p
WHERE ppr.project_id = p.id
AND NOT EXISTS (SELECT 1
FROM team_profile_rel AS tpr
WHERE tpr.team_id = p.team_id
AND tpr.profile_id = ppr.profile_id);
DELETE FROM team_project_profile_rel AS tppr
WHERE NOT EXISTS (SELECT 1
FROM team_profile_rel AS tpr
WHERE tpr.team_id = tppr.team_id
AND tpr.profile_id = tppr.profile_id);

View File

@ -41,8 +41,13 @@
ppr.is_admin,
ppr.can_edit
from project_profile_rel as ppr
inner join project as p on (p.id = ppr.project_id)
where ppr.project_id = ?
and ppr.profile_id = ?")
and ppr.profile_id = ?
and exists (select 1
from team_profile_rel as tpr
where tpr.team_id = p.team_id
and tpr.profile_id = ppr.profile_id)")
(defn- get-permissions
[conn profile-id project-id]
@ -143,7 +148,12 @@
inner join team as t on (t.id = p2.team_id)
where p2.id in (select project_id
from project_profile_rel as ppr
inner join project as p on (p.id = ppr.project_id)
where ppr.profile_id = ?
and exists (select 1
from team_profile_rel as tpr
where tpr.team_id = p.team_id
and tpr.profile_id = ppr.profile_id)
and (ppr.can_edit = true or
ppr.is_owner = true or
ppr.is_admin = true))

View File

@ -32,6 +32,10 @@
where ppr.profile_id = ?
and p.team_id = ?
and (p.deleted_at is null)
and exists (select 1
from team_profile_rel as tpr
where tpr.team_id = p.team_id
and tpr.profile_id = ppr.profile_id)
and (ppr.is_admin = true or
ppr.is_owner = true or
ppr.can_edit = true)

View File

@ -732,6 +732,37 @@
;; --- Mutation: Leave Team
;; Remove every team-scoped relation of a profile that leaves (or is
;; removed from) a team. File and project roles hang from the team
;; membership: keeping them would let the former member keep reading
;; and editing files through `sql:file-permissions`. Pins are personal
;; state of the same team, so they go too. Other teams are untouched,
;; and rejoining grants fresh roles. Runs inside the caller's
;; transaction.
(def ^:private sql:delete-team-file-rels
"DELETE FROM file_profile_rel AS fpr
USING file AS f
INNER JOIN project AS p ON (p.id = f.project_id)
WHERE fpr.file_id = f.id
AND p.team_id = ?
AND fpr.profile_id = ?")
(def ^:private sql:delete-team-project-rels
"DELETE FROM project_profile_rel AS ppr
USING project AS p
WHERE ppr.project_id = p.id
AND p.team_id = ?
AND ppr.profile_id = ?")
(defn- delete-team-rels
[conn profile-id team-id]
(db/exec! conn [sql:delete-team-file-rels team-id profile-id])
(db/exec! conn [sql:delete-team-project-rels team-id profile-id])
(db/delete! conn :team-project-profile-rel {:profile-id profile-id
:team-id team-id})
(db/delete! conn :team-profile-rel {:profile-id profile-id
:team-id team-id}))
(defn leave-team
[{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id id reassign-to]}]
(let [perms (get-permissions conn profile-id id)
@ -782,9 +813,7 @@
:code :owner-cant-leave-team
:hint "releasing owner before leave"))
(db/delete! conn :team-profile-rel
{:profile-id profile-id
:team-id id})
(delete-team-rels conn profile-id id)
nil))
@ -966,8 +995,7 @@
(ex/raise :type :validation
:code :cant-remove-owner))
(db/delete! conn :team-profile-rel {:profile-id member-id
:team-id team-id})
(delete-team-rels conn member-id team-id)
;; A removed member that owns the organization of this team keeps
;; read-only access to it, so instead of kicking them out we degrade

View File

@ -299,9 +299,7 @@
([params] (create-project-role* *system* params))
([system {:keys [project-id profile-id role] :or {role :owner}}]
(dm/with-open [conn (db/open system)]
(#'teams/create-project-role conn {:project-id project-id
:profile-id profile-id
:role role}))))
(#'teams/create-project-role conn profile-id project-id role))))
(defn create-file-role*
([params] (create-file-role* *system* params))

View File

@ -1471,3 +1471,315 @@
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :not-found))
(t/is (th/ex-of-code? (:error out) :member-does-not-exist)))))
(t/deftest leaver-loses-file-access
;; GHSA-v9r9-h77c-55m2: leaving the team must revoke the file and
;; project roles granted in that team.
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project)})]
(t/testing "editor can rename and read summary before leaving"
(let [out (th/command! {::th/type :rename-file
::rpc/profile-id (:id editor)
:id (:id file)
:name "renamed"})]
(t/is (th/success? out)))
(let [out (th/command! {::th/type :get-file-summary
::rpc/profile-id (:id editor)
:id (:id file)})]
(t/is (th/success? out))))
(t/testing "leave team"
(let [out (th/command! {::th/type :leave-team
::rpc/profile-id (:id editor)
:id (:id team)})]
(t/is (th/success? out))))
(t/testing "renaming after leaving fails with not-found"
(let [out (th/command! {::th/type :rename-file
::rpc/profile-id (:id editor)
:id (:id file)
:name "renamed-again"})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/testing "file summary after leaving fails with not-found"
(let [out (th/command! {::th/type :get-file-summary
::rpc/profile-id (:id editor)
:id (:id file)})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/testing "get-file after leaving fails with not-found"
(let [out (th/command! {::th/type :get-file
::rpc/profile-id (:id editor)
:id (:id file)
:components-v2 true})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))))
(t/deftest removed-member-loses-file-access
;; Same as above through the expulsion path.
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project)})]
(t/testing "remove member"
(let [out (th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)})]
(t/is (th/success? out))))
(t/testing "renaming after removal fails with not-found"
(let [out (th/command! {::th/type :rename-file
::rpc/profile-id (:id editor)
:id (:id file)
:name "renamed-again"})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/testing "file summary after removal fails with not-found"
(let [out (th/command! {::th/type :get-file-summary
::rpc/profile-id (:id editor)
:id (:id file)})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))))
(t/deftest orphan-file-role-without-team-membership-grants-nothing
;; Defense in depth: a file role row without a live team membership
;; (leftover from before the leave-team cleanup) grants no access.
(let [owner (th/create-profile* 1 {:is-active true})
stranger (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})]
;; Plant an orphan file role for a profile that was never a member.
(th/create-file-role* {:file-id (:id file)
:profile-id (:id stranger)
:role :editor})
(t/testing "orphan file role cannot rename"
(let [out (th/command! {::th/type :rename-file
::rpc/profile-id (:id stranger)
:id (:id file)
:name "renamed"})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/testing "orphan file role cannot read summary"
(let [out (th/command! {::th/type :get-file-summary
::rpc/profile-id (:id stranger)
:id (:id file)})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))))
(t/deftest leaver-loses-project-access
;; Same revocation at project level (covers sql:project-permissions).
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})]
(t/testing "leave team"
(let [out (th/command! {::th/type :leave-team
::rpc/profile-id (:id editor)
:id (:id team)})]
(t/is (th/success? out))))
(t/testing "get-project after leaving fails with not-found"
(let [out (th/command! {::th/type :get-project
::rpc/profile-id (:id editor)
:id (:id project)})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/testing "rename-project after leaving fails with not-found"
(let [out (th/command! {::th/type :rename-project
::rpc/profile-id (:id editor)
:id (:id project)
:name "renamed"})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))))
(t/deftest leaver-vanishes-from-search
;; The old team's files are no longer searchable by the leaver.
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project)
:name "searchable-file"})]
(t/testing "member finds the file before leaving"
(let [out (th/command! {::th/type :search-files
::rpc/profile-id (:id editor)
:team-id (:id team)
:search-term "searchable"})]
(t/is (th/success? out))
(t/is (= (:id file) (-> out :result first :id)))))
(t/testing "leave team"
(let [out (th/command! {::th/type :leave-team
::rpc/profile-id (:id editor)
:id (:id team)})]
(t/is (th/success? out))))
(t/testing "search on the old team fails with not-found"
(let [out (th/command! {::th/type :search-files
::rpc/profile-id (:id editor)
:team-id (:id team)
:search-term "searchable"})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found)))))))
(t/deftest pins-are-cleaned-on-leave
;; The project pin (team-project-profile-rel) goes with the leaver.
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [project (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team)})]
(t/testing "pin the project"
(let [out (th/command! {::th/type :update-project-pin
::rpc/profile-id (:id editor)
:id (:id project)
:team-id (:id team)
:is-pinned true})]
(t/is (th/success? out)))
(t/is (= 1 (count (th/db-query :team-project-profile-rel
{:team-id (:id team)
:profile-id (:id editor)})))))
(t/testing "leave team removes the pin row"
(let [out (th/command! {::th/type :leave-team
::rpc/profile-id (:id editor)
:id (:id team)})]
(t/is (th/success? out)))
(t/is (= 0 (count (th/db-query :team-project-profile-rel
{:team-id (:id team)
:profile-id (:id editor)}))))))))
(t/deftest leaving-one-team-keeps-other-team-access
;; Deletes are scoped by team: leaving A keeps B's files usable.
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team-a (th/create-team* 1 {:profile-id (:id owner)})
team-b (th/create-team* 2 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team-a)
:profile-id (:id editor)
:role :editor})
(th/create-team-role* {:team-id (:id team-b)
:profile-id (:id editor)
:role :editor})
(let [project-a (th/create-project* 1 {:profile-id (:id editor)
:team-id (:id team-a)})
file-a (th/create-file* 1 {:profile-id (:id editor)
:project-id (:id project-a)})
project-b (th/create-project* 2 {:profile-id (:id editor)
:team-id (:id team-b)})
file-b (th/create-file* 2 {:profile-id (:id editor)
:project-id (:id project-b)})]
(t/testing "leave team A"
(let [out (th/command! {::th/type :leave-team
::rpc/profile-id (:id editor)
:id (:id team-a)})]
(t/is (th/success? out))))
(t/testing "team A file is gone"
(let [out (th/command! {::th/type :rename-file
::rpc/profile-id (:id editor)
:id (:id file-a)
:name "renamed"})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/testing "team B file still works"
(let [out (th/command! {::th/type :rename-file
::rpc/profile-id (:id editor)
:id (:id file-b)
:name "renamed"})]
(t/is (th/success? out)))))))
(t/deftest orphan-project-role-grants-nothing-and-lists-nothing
;; Mirror of the file orphan test at project level, including the
;; get-all-projects listing (covers the F1 guard).
(let [owner (th/create-profile* 1 {:is-active true})
stranger (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})]
;; Plant an orphan project role for a profile that was never a member.
(th/create-project-role* {:project-id (:id project)
:profile-id (:id stranger)
:role :editor})
(t/testing "orphan project role cannot read the project"
(let [out (th/command! {::th/type :get-project
::rpc/profile-id (:id stranger)
:id (:id project)})
error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
(t/testing "orphan project is not listed"
(let [out (th/command! {::th/type :get-all-projects
::rpc/profile-id (:id stranger)})]
(t/is (th/success? out))
(t/is (nil? (some #(= (:id project) (:id %)) (:result out))))))))