diff --git a/crates/manager-core/src/app_members_repo.rs b/crates/manager-core/src/app_members_repo.rs
index 6a7fb06..01d961b 100644
--- a/crates/manager-core/src/app_members_repo.rs
+++ b/crates/manager-core/src/app_members_repo.rs
@@ -8,11 +8,17 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
-use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
+use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
use sqlx::PgPool;
use crate::authz::{AuthzError, AuthzRepo};
+/// SQL fragment ranking the three role literals by authority so a CTE can
+/// `MAX` over a mixed set of app_members + group_members rows. Shared by
+/// both effective-role queries.
+const ROLE_RANK_CTE: &str =
+ "role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
+
#[derive(Debug, thiserror::Error)]
pub enum AppMembersRepositoryError {
#[error("database error: {0}")]
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
impl AuthzRepo for PostgresAppMembersRepository {
async fn membership(
&self,
- user_id: AdminUserId,
+ user_id: UserId,
app_id: AppId,
) -> Result