Require at least one capability to see/access apps that define them

An app-level grant (user_app_perms/role_app_perms) alone used to be
enough to show an app's portal tile and pass AuthGate, even with zero
capabilities held. Most apps only gate specific routes/buttons with
capabilities, not their dashboard/shell, so a role with the app toggled
on but no caps granted (e.g. wages, utilities, hvac, plant,
tablet-checkin — none of which auto-grant Staff caps) still saw the
full app. Apps with no capabilities defined at all keep prior behaviour.
This commit is contained in:
jtricerolph 2026-09-25 09:22:47 +00:00
parent f756a600b1
commit 97a0b50c92

View file

@ -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 "<app_slug>:<cap_slug>" 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
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]
)