diff --git a/src/routes/auth.js b/src/routes/auth.js index ca05c6a..8b9533d 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -79,6 +79,31 @@ export function cookieOpts(request, clear = false) { } } +// An app-level grant (user_app_perms / role_app_perms) alone used to be enough +// to show an app's tile and let AuthGate in — but most apps only enforce their +// granular capabilities on specific routes/buttons, not on the dashboard/shell +// itself. That meant a role with the app toggled on and zero capabilities still +// saw the full app. For any app that defines capabilities at all, require the +// user to hold at least one of them; apps with no capabilities defined yet keep +// the old app-perm-only behaviour unchanged. +function CAP_GATE_CLAUSE(appIdExpr, userIdParam) { + return `( + NOT EXISTS (SELECT 1 FROM app_capabilities ac WHERE ac.app_id = ${appIdExpr}) + OR EXISTS ( + SELECT 1 FROM app_capabilities ac + WHERE ac.app_id = ${appIdExpr} + AND ( + EXISTS (SELECT 1 FROM user_capabilities uc WHERE uc.user_id = ${userIdParam} AND uc.capability_id = ac.id) + OR EXISTS ( + SELECT 1 FROM user_roles ur2 + JOIN role_capabilities rc ON rc.role_id = ur2.role_id + WHERE ur2.user_id = ${userIdParam} AND rc.capability_id = ac.id + ) + ) + ) + )` +} + // Resolve a user's granular capabilities as ":" strings. // Admins implicitly hold every capability of every active app; everyone else // gets the union of capabilities granted via their roles and direct grants. @@ -136,6 +161,7 @@ export async function getUserWithApps(userId) { WHERE ur.user_id = $1 AND rap.app_id = a.id ) ) + AND ${CAP_GATE_CLAUSE('a.id', '$1')} ORDER BY a.category NULLS FIRST, a.name`, [userId] ) @@ -218,6 +244,7 @@ export async function authRoutes(app) { const { app: appSlug } = request.query // Live DB check: user must be active and (if app requested) have permission + // AND (for apps that define capabilities) hold at least one of them. const { rows: [user] } = await pool.query( `SELECT u.id, u.email, u.name, u.is_admin, u.offsite_allowed FROM users u @@ -225,13 +252,18 @@ export async function authRoutes(app) { AND ( $2::text IS NULL OR u.is_admin = true - OR EXISTS (SELECT 1 FROM user_app_perms p - JOIN apps a ON a.id = p.app_id - WHERE p.user_id = u.id AND a.slug = $2 AND a.active = true) - OR EXISTS (SELECT 1 FROM user_roles ur - JOIN role_app_perms rap ON rap.role_id = ur.role_id - JOIN apps a ON a.id = rap.app_id - WHERE ur.user_id = u.id AND a.slug = $2 AND a.active = true) + OR ( + ( + EXISTS (SELECT 1 FROM user_app_perms p + JOIN apps a ON a.id = p.app_id + WHERE p.user_id = u.id AND a.slug = $2 AND a.active = true) + OR EXISTS (SELECT 1 FROM user_roles ur + JOIN role_app_perms rap ON rap.role_id = ur.role_id + JOIN apps a ON a.id = rap.app_id + WHERE ur.user_id = u.id AND a.slug = $2 AND a.active = true) + ) + AND ${CAP_GATE_CLAUSE('(SELECT id FROM apps WHERE slug = $2)', 'u.id')} + ) )`, [payload.user_id, appSlug || null] ) @@ -282,7 +314,8 @@ export async function authRoutes(app) { JOIN role_app_perms rap ON rap.role_id = ur.role_id WHERE ur.user_id = $1 AND rap.app_id = a.id ) - )`, + ) + AND ${CAP_GATE_CLAUSE('a.id', '$1')}`, [user.id] )