diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index f0dd9fe7..b441dfc2 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -11,8 +11,11 @@ import { appOpensTable, userGroupsTable, groupAppsTable, + groupRolesTable, + groupsTable, auditLogsTable, permissionsTable, + usersTable, } from "@workspace/db"; import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth"; import { @@ -355,6 +358,188 @@ router.get("/apps/:id/permissions", requireAdmin, async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + const candidate = req.body?.permissionIds; + if ( + !Array.isArray(candidate) || + !candidate.every((n) => Number.isInteger(n) && n > 0) + ) { + res + .status(400) + .json({ error: "permissionIds must be an array of positive integers" }); + return; + } + + const [app] = await db + .select({ id: appsTable.id }) + .from(appsTable) + .where(eq(appsTable.id, id)); + if (!app) { + res.status(404).json({ error: "App not found" }); + return; + } + + const candidateIds = Array.from(new Set(candidate as number[])); + const candidateSet = new Set(candidateIds); + + const currentRows = await db + .select({ permissionId: appPermissionsTable.permissionId }) + .from(appPermissionsTable) + .where(eq(appPermissionsTable.appId, id)); + const currentIds = currentRows.map((r) => r.permissionId); + const currentSet = new Set(currentIds); + + // Groups that grant this app via group_apps. These groups offset the + // permission gate: members of any of these groups always see the app + // regardless of permission changes, so we surface them so the admin + // knows which populations are protected. + const groupGrantRows = await db + .select({ id: groupsTable.id, name: groupsTable.name }) + .from(groupAppsTable) + .innerJoin(groupsTable, eq(groupAppsTable.groupId, groupsTable.id)) + .where(eq(groupAppsTable.appId, id)) + .orderBy(groupsTable.name); + + const sameSize = candidateSet.size === currentSet.size; + const noChange = + sameSize && currentIds.every((p) => candidateSet.has(p)); + + const groupGrantUserRows = await db + .selectDistinct({ userId: userGroupsTable.userId }) + .from(userGroupsTable) + .innerJoin( + groupAppsTable, + eq(groupAppsTable.groupId, userGroupsTable.groupId), + ) + .where(eq(groupAppsTable.appId, id)); + const groupGrantUserSet = new Set( + groupGrantUserRows.map((r) => r.userId), + ); + + // Admins are excluded from the affected/visible counts since they always + // see every app via the short-circuit in getVisibleAppsForUser. We treat + // an admin as anyone holding the "admin" role directly OR via a group. + const adminRoleRows = await db + .select({ id: rolesTable.id }) + .from(rolesTable) + .where(eq(rolesTable.name, "admin")); + const adminUserSet = new Set(); + if (adminRoleRows.length > 0) { + const adminRoleId = adminRoleRows[0].id; + const directAdmins = await db + .select({ userId: userRolesTable.userId }) + .from(userRolesTable) + .where(eq(userRolesTable.roleId, adminRoleId)); + for (const r of directAdmins) adminUserSet.add(r.userId); + + const adminGroupRows = await db + .select({ groupId: groupRolesTable.groupId }) + .from(groupRolesTable) + .where(eq(groupRolesTable.roleId, adminRoleId)); + const adminGroupIds = adminGroupRows.map((r) => r.groupId); + if (adminGroupIds.length > 0) { + const indirectAdmins = await db + .select({ userId: userGroupsTable.userId }) + .from(userGroupsTable) + .where(inArray(userGroupsTable.groupId, adminGroupIds)); + for (const r of indirectAdmins) adminUserSet.add(r.userId); + } + } + + // Returns the set of user IDs that currently hold AT LEAST ONE of `perms` + // through any role they have (direct or via a group). Mirrors the OR + // semantics getVisibleAppsForUser uses for app permission gating. + const usersWithAnyPermission = async ( + perms: number[], + ): Promise> => { + const out = new Set(); + if (perms.length === 0) return out; + const directRows = await db + .selectDistinct({ userId: userRolesTable.userId }) + .from(userRolesTable) + .innerJoin( + rolePermissionsTable, + eq(rolePermissionsTable.roleId, userRolesTable.roleId), + ) + .where(inArray(rolePermissionsTable.permissionId, perms)); + for (const r of directRows) out.add(r.userId); + const indirectRows = await db + .selectDistinct({ userId: userGroupsTable.userId }) + .from(userGroupsTable) + .innerJoin( + groupRolesTable, + eq(groupRolesTable.groupId, userGroupsTable.groupId), + ) + .innerJoin( + rolePermissionsTable, + eq(rolePermissionsTable.roleId, groupRolesTable.roleId), + ) + .where(inArray(rolePermissionsTable.permissionId, perms)); + for (const r of indirectRows) out.add(r.userId); + return out; + }; + + // Helper to materialise "all non-admin users" only when one of the + // candidate or current sets is empty (the unrestricted path). Skipping + // this query in the common restricted-to-restricted case keeps the + // endpoint cheap on installs with many users. + let allNonAdminUserIds: number[] | null = null; + const getAllNonAdminUsers = async (): Promise> => { + if (allNonAdminUserIds === null) { + const allUsers = await db + .select({ id: usersTable.id }) + .from(usersTable); + allNonAdminUserIds = allUsers + .map((u) => u.id) + .filter((uid) => !adminUserSet.has(uid)); + } + return new Set(allNonAdminUserIds); + }; + + const computeVisible = async (perms: number[]): Promise> => { + if (perms.length === 0) { + // Unrestricted: every non-admin user can see the app. + return await getAllNonAdminUsers(); + } + const visible = await usersWithAnyPermission(perms); + for (const uid of groupGrantUserSet) visible.add(uid); + for (const uid of adminUserSet) visible.delete(uid); + return visible; + }; + + const currentlyVisible = await computeVisible(currentIds); + // When the candidate set is identical to the current set we skip the + // second visibility query (it's guaranteed to be the same) and report + // the real currentlyVisibleUserCount so consumers reading a noChange + // response still get an accurate audience size for the app. + const futureVisible = noChange + ? currentlyVisible + : await computeVisible(candidateIds); + + let affected = 0; + if (!noChange) { + for (const uid of currentlyVisible) { + if (!futureVisible.has(uid)) affected += 1; + } + } + + res.json({ + affectedUserCount: affected, + currentlyVisibleUserCount: currentlyVisible.size, + groups: groupGrantRows, + noChange, + }); + }, +); + router.post("/apps/:id/permissions", requireAdmin, async (req, res): Promise => { const params = GetAppParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/api-server/tests/app-permissions-impact.test.mjs b/artifacts/api-server/tests/app-permissions-impact.test.mjs new file mode 100644 index 00000000..9e99534e --- /dev/null +++ b/artifacts/api-server/tests/app-permissions-impact.test.mjs @@ -0,0 +1,441 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +let adminUsername; +let adminCookie; +let consumerUsername; +let consumerCookie; + +const createdAppIds = []; +const createdPermissionIds = []; +const createdRoleIds = []; +const createdGroupIds = []; +const createdUserIds = []; + +let appId; +let permA; +let permB; +let permC; +let roleA; +let roleB; +let roleC; +let userA; // direct role A only — has permA +let userAB; // direct roles A and B — has permA and permB +let userViaGroup; // in a group that has role A — has permA via group +let helperGroupId; +let groupGrantedAppId; // app explicitly granted to a group via group_apps +let groupAppGrantUserId; // member of that group; sees the app even with no perms +let groupAppGroupId; + +async function login(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + return setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); +} + +async function makeUser(prefix, stamp) { + const username = `${prefix}_${stamp}`; + const r = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, + [ + username, + `${username}@example.com`, + TEST_PASSWORD_HASH, + `${prefix} User`, + ], + ); + const id = r.rows[0].id; + createdUserIds.push(id); + return { id, username }; +} + +async function makePermission(prefix, stamp) { + const r = await pool.query( + `INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id, name`, + [`${prefix}.${stamp}`, `${prefix} permission`], + ); + const row = r.rows[0]; + createdPermissionIds.push(row.id); + return row; +} + +async function makeRole(prefix, stamp, perms) { + const r = await pool.query( + `INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`, + [`${prefix}_${stamp}`, `${prefix} role`], + ); + const id = r.rows[0].id; + createdRoleIds.push(id); + for (const p of perms) { + await pool.query( + `INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`, + [id, p.id], + ); + } + return id; +} + +async function makeGroup(prefix, stamp) { + const r = await pool.query( + `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`, + [`${prefix}_${stamp}`, `${prefix} group`], + ); + const id = r.rows[0].id; + createdGroupIds.push(id); + return id; +} + +async function makeApp(prefix, stamp) { + const slug = `${prefix}_${stamp}`; + const r = await pool.query( + `INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order) + VALUES ($1, $2, $3, $4, true, 999) RETURNING id`, + [slug, slug, slug, `/${slug}`], + ); + const id = r.rows[0].id; + createdAppIds.push(id); + return id; +} + +before(async () => { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + + // Admin user (granted system-wide admin role). + const admin = await makeUser("api_admin", stamp); + adminUsername = admin.username; + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [admin.id], + ); + + // Plain consumer (used to verify the endpoint requires admin). + const consumer = await makeUser("api_consumer", stamp); + consumerUsername = consumer.username; + + adminCookie = await login(adminUsername, TEST_PASSWORD); + consumerCookie = await login(consumerUsername, TEST_PASSWORD); + + permA = await makePermission("api.perm.a", stamp); + permB = await makePermission("api.perm.b", stamp); + permC = await makePermission("api.perm.c", stamp); + + roleA = await makeRole("api_role_a", stamp, [permA]); + roleB = await makeRole("api_role_b", stamp, [permB]); + roleC = await makeRole("api_role_c", stamp, [permC]); + + // userA: direct roleA → has permA. + ({ id: userA } = await makeUser("api_user_a", stamp)); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`, + [userA, roleA], + ); + + // userAB: direct roles A and B → has permA and permB. + ({ id: userAB } = await makeUser("api_user_ab", stamp)); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2), ($1, $3)`, + [userAB, roleA, roleB], + ); + + // userViaGroup: gets roleA via membership in helperGroup → has permA via group. + ({ id: userViaGroup } = await makeUser("api_user_viagrp", stamp)); + helperGroupId = await makeGroup("api_helper_grp", stamp); + await pool.query( + `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, + [userViaGroup, helperGroupId], + ); + await pool.query( + `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`, + [helperGroupId, roleA], + ); + + // App that we will run impact previews against. + appId = await makeApp("api_impact_app", stamp); + + // Separate scenario for the group-grant offset: an app with no permissions + // but explicitly granted to a group via group_apps. A member of that group + // sees the app even after we add a permission requirement they don't have. + groupGrantedAppId = await makeApp("api_grpapp", stamp); + groupAppGroupId = await makeGroup("api_grpapp_grp", stamp); + await pool.query( + `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`, + [groupAppGroupId, groupGrantedAppId], + ); + ({ id: groupAppGrantUserId } = await makeUser("api_grpapp_user", stamp)); + await pool.query( + `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, + [groupAppGrantUserId, groupAppGroupId], + ); +}); + +after(async () => { + if (createdAppIds.length) { + await pool.query( + `DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`, + [createdAppIds], + ); + await pool.query( + `DELETE FROM group_apps WHERE app_id = ANY($1::int[])`, + [createdAppIds], + ); + await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [ + createdAppIds, + ]); + } + if (createdRoleIds.length) { + await pool.query(`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`, [ + createdRoleIds, + ]); + await pool.query(`DELETE FROM group_roles WHERE role_id = ANY($1::int[])`, [ + createdRoleIds, + ]); + await pool.query( + `DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`, + [createdRoleIds], + ); + await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [ + createdRoleIds, + ]); + } + if (createdGroupIds.length) { + await pool.query( + `DELETE FROM group_roles WHERE group_id = ANY($1::int[])`, + [createdGroupIds], + ); + await pool.query( + `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`, + [createdGroupIds], + ); + await pool.query( + `DELETE FROM group_apps WHERE group_id = ANY($1::int[])`, + [createdGroupIds], + ); + await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [ + createdGroupIds, + ]); + } + if (createdPermissionIds.length) { + await pool.query( + `DELETE FROM role_permissions WHERE permission_id = ANY($1::int[])`, + [createdPermissionIds], + ); + await pool.query( + `DELETE FROM app_permissions WHERE permission_id = ANY($1::int[])`, + [createdPermissionIds], + ); + await pool.query(`DELETE FROM permissions WHERE id = ANY($1::int[])`, [ + createdPermissionIds, + ]); + } + for (const uid of createdUserIds) { + await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]); + await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]); + await pool.query(`DELETE FROM users WHERE id = $1`, [uid]); + } + await pool.end(); +}); + +async function preview(appIdToCheck, permissionIds, cookie = adminCookie) { + return fetch( + `${API_BASE}/api/apps/${appIdToCheck}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ permissionIds }), + }, + ); +} + +test("preview short-circuits with noChange=true when candidate matches current set", async () => { + // Seed app with permA so the candidate exactly matches. + await pool.query( + `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [appId, permA.id], + ); + try { + const res = await preview(appId, [permA.id]); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.noChange, true); + assert.equal(body.affectedUserCount, 0); + // currentlyVisibleUserCount must be the real audience size — not + // hardcoded to 0 — so consumers reading a noChange response still + // get an accurate count of how many users currently see the app. + // At minimum userA, userAB, and userViaGroup hold permA and should + // appear in the count. + assert.ok( + body.currentlyVisibleUserCount >= 3, + `expected currentlyVisibleUserCount >= 3, got ${body.currentlyVisibleUserCount}`, + ); + assert.deepEqual(body.groups, []); + } finally { + await pool.query( + `DELETE FROM app_permissions WHERE app_id = $1 AND permission_id = $2`, + [appId, permA.id], + ); + } +}); + +test("preview reports affected users when adding a permission requirement to an unrestricted app", async () => { + // App is currently unrestricted — every non-admin user can see it. + // Candidate: require permB. Only userAB has permB. + // Expected: at minimum, userA and userViaGroup lose access (they have + // permA only). Other unrelated users in the DB also currently see the + // app (since it's unrestricted), so we use a >= assertion to stay + // robust against test data drift in the surrounding DB. + const res = await preview(appId, [permB.id]); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.noChange, false); + // Sanity: a positive number of users currently see the unrestricted app + // and at least the two test users without permB lose access. + assert.ok(body.currentlyVisibleUserCount >= 3); + assert.ok( + body.affectedUserCount >= 2, + `expected at least 2 affected, got ${body.affectedUserCount}`, + ); + // userAB has permB so they keep access — affected must be strictly less + // than currently visible. + assert.ok(body.affectedUserCount < body.currentlyVisibleUserCount); +}); + +test("preview narrows affected users when candidate keeps a permission they already hold", async () => { + // Seed the app with permA AND permB required (OR semantics: holding + // either one grants visibility). Then ask "what if we remove permB?" + // — which keeps permA. Only users who exclusively have permB lose + // access, so userA and userViaGroup are unaffected (they still have + // permA). userAB also keeps access via permA. + await pool.query( + `INSERT INTO app_permissions (app_id, permission_id) VALUES + ($1, $2), ($1, $3) ON CONFLICT DO NOTHING`, + [appId, permA.id, permB.id], + ); + try { + const res = await preview(appId, [permA.id]); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.noChange, false); + // None of our scenario users should lose access since they all have + // permA (or are in a group that grants permA). Other users in the + // surrounding DB might lose access if they only had permB, but our + // three tracked users must NOT. + // We verify by sanity-checking that currentlyVisibleUserCount stays + // sensible; the strict invariant we rely on is the comparison below. + assert.ok(body.currentlyVisibleUserCount >= 3); + // affected must be < currentlyVisibleUserCount because userA, userAB + // and userViaGroup all keep access via permA. + assert.ok(body.affectedUserCount < body.currentlyVisibleUserCount); + } finally { + await pool.query( + `DELETE FROM app_permissions WHERE app_id = $1 AND permission_id IN ($2, $3)`, + [appId, permA.id, permB.id], + ); + } +}); + +test("preview surfaces groups that grant the app via group_apps and they offset the loss", async () => { + // groupGrantedAppId is unrestricted and explicitly granted to + // groupAppGroupId. Adding a requirement that no test user holds + // (permC, only assigned to roleC, which nobody in our scenario has) + // would otherwise hide the app from groupAppGrantUserId — but that + // user is in a group that grants the app, so they keep access. + const res = await preview(groupGrantedAppId, [permC.id]); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.noChange, false); + // Groups list must include the group that grants the app. + const groupNames = body.groups.map((g) => g.id); + assert.ok( + groupNames.includes(groupAppGroupId), + `expected groups to include ${groupAppGroupId}, got ${JSON.stringify(body.groups)}`, + ); + // groupAppGrantUserId must NOT be counted as affected — they keep + // access via the group_apps grant. We can't directly read the affected + // user IDs (the endpoint only returns counts), but we can prove the + // offset works by re-running the preview against an *identical app* + // without the group-grant and confirming the count is strictly higher + // by at least 1. We achieve this by inserting and removing a + // group_apps row on the same app. + await pool.query( + `DELETE FROM group_apps WHERE group_id = $1 AND app_id = $2`, + [groupAppGroupId, groupGrantedAppId], + ); + try { + const without = await preview(groupGrantedAppId, [permC.id]); + assert.equal(without.status, 200); + const wbody = await without.json(); + // Removing the group grant should INCREASE affectedUserCount by at + // least 1 (groupAppGrantUserId now has no offset). + assert.ok( + wbody.affectedUserCount >= body.affectedUserCount + 1, + `expected affected count to grow when group_apps offset is removed (with offset: ${body.affectedUserCount}, without: ${wbody.affectedUserCount})`, + ); + } finally { + // Restore the grant so other tests / cleanup remain happy. + await pool.query( + `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [groupAppGroupId, groupGrantedAppId], + ); + } +}); + +test("preview returns 404 for an unknown app", async () => { + const max = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`); + const unknownId = max.rows[0].m + 9999; + const res = await preview(unknownId, [permA.id]); + assert.equal(res.status, 404); +}); + +test("preview returns 400 when permissionIds is missing or invalid", async () => { + const noBody = await fetch( + `${API_BASE}/api/apps/${appId}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({}), + }, + ); + assert.equal(noBody.status, 400); + + const wrong = await fetch( + `${API_BASE}/api/apps/${appId}/permissions/impact-preview`, + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionIds: ["abc"] }), + }, + ); + assert.equal(wrong.status, 400); +}); + +test("preview requires admin", async () => { + const res = await preview(appId, [permA.id], consumerCookie); + assert.ok( + res.status === 401 || res.status === 403, + `expected 401/403, got ${res.status}`, + ); +}); diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 5792704d..d9336aa6 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index cb09559e..97b54e82 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -354,7 +354,16 @@ "none": "لا توجد صلاحيات مطلوبة — التطبيق ظاهر للجميع.", "selectPlaceholder": "اختر صلاحية…", "add": "إضافة", - "removeAria": "إزالة الصلاحية {{name}}" + "removeAria": "إزالة الصلاحية {{name}}", + "impactTitle": "معاينة الأثر", + "impactLoading": "جارٍ حساب الأثر…", + "impactError": "تعذّر تحميل معاينة الأثر. حاول مرة أخرى.", + "impactNone": "لن يفقد أي مستخدم حق الوصول. {{visible}} مستخدمًا يرون هذا التطبيق حاليًا.", + "impactSummary": "{{affected}} من أصل {{visible}} مستخدمًا يرون هذا التطبيق حاليًا سيفقدون حق الوصول.", + "impactViaGroups": "أعضاء هذه المجموعات يحتفظون بحق الوصول بغض النظر عن التغيير: {{groups}}.", + "confirmTitle": "تأكيد تغيير الصلاحيات", + "confirmBody": "إضافة هذا الاشتراط ستخفي التطبيق عن {{affected}} من أصل {{visible}} مستخدمًا يرونه حاليًا. يمكن التراجع عن هذا التغيير.", + "confirmAction": "إضافة على أي حال" }, "serviceName": "اسم الخدمة", "serviceNameAr": "اسم الخدمة (عربي)", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 50b6b8c6..164e68a7 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -351,7 +351,16 @@ "none": "No permissions required — visible to everyone.", "selectPlaceholder": "Select a permission…", "add": "Add", - "removeAria": "Remove permission {{name}}" + "removeAria": "Remove permission {{name}}", + "impactTitle": "Impact preview", + "impactLoading": "Calculating impact…", + "impactError": "Couldn't load impact preview. Try again.", + "impactNone": "No users would lose access. {{visible}} currently see this app.", + "impactSummary": "{{affected}} of {{visible}} users currently seeing this app would lose access.", + "impactViaGroups": "Members of these groups keep access regardless: {{groups}}.", + "confirmTitle": "Confirm permission change", + "confirmBody": "Adding this requirement will hide the app from {{affected}} of {{visible}} users who currently see it. This change is reversible.", + "confirmAction": "Add anyway" }, "serviceName": "Service Name", "serviceNameAr": "Service Name (Arabic)", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index ebc8a0a4..784443a3 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -9,6 +9,7 @@ import { getListAppPermissionsQueryKey, useAddAppPermission, useRemoveAppPermission, + usePreviewAppPermissionsImpact, useListServices, getListServicesQueryKey, useCreateService, @@ -89,6 +90,7 @@ import type { ListAuditLogsParams, AuditLogEntry, RolePermissionsImpact, + AppPermissionsImpact, GetRolePermissionAuditParams, RolePermissionAuditEntry, GetUserPermissionAuditParams, @@ -262,7 +264,13 @@ function AppPermissionsEditor({ appId }: { appId: number }) { }); const addPermission = useAddAppPermission(); const removePermission = useRemoveAppPermission(); + const previewImpact = usePreviewAppPermissionsImpact(); const [pendingId, setPendingId] = useState(""); + const [impact, setImpact] = useState(null); + const [impactLoading, setImpactLoading] = useState(false); + const [impactError, setImpactError] = useState(false); + const [confirmAdd, setConfirmAdd] = useState(false); + const impactRequestSeq = useRef(0); const assignedIds = useMemo( () => new Set((assigned ?? []).map((p) => p.id)), @@ -280,13 +288,61 @@ function AppPermissionsEditor({ appId }: { appId: number }) { queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); }; - const onAdd = () => { + // Debounced, cancel-safe impact preview for the candidate "add this + // permission" change. Mirrors the RolesPanel UX so admins see the same + // kind of warning before they tighten an app's gate. Stale responses + // are dropped via impactRequestSeq. + useEffect(() => { + if (pendingId === "" || typeof pendingId !== "number") { + setImpact(null); + setImpactLoading(false); + setImpactError(false); + return; + } + const candidate = Array.from(assignedIds); + if (!candidate.includes(pendingId)) candidate.push(pendingId); + setImpactLoading(true); + setImpactError(false); + const requestId = ++impactRequestSeq.current; + const handle = setTimeout(() => { + previewImpact.mutate( + { id: appId, data: { permissionIds: candidate } }, + { + onSuccess: (data) => { + if (impactRequestSeq.current !== requestId) return; + setImpact(data); + setImpactError(false); + setImpactLoading(false); + }, + onError: () => { + if (impactRequestSeq.current !== requestId) return; + setImpact(null); + setImpactError(true); + setImpactLoading(false); + }, + }, + ); + }, 350); + return () => clearTimeout(handle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appId, pendingId, assignedIds]); + + const requiresConfirmation = + !impactLoading && + !impactError && + impact != null && + impact.affectedUserCount > 0; + + const performAdd = () => { if (pendingId === "" || typeof pendingId !== "number") return; addPermission.mutate( { id: appId, data: { permissionId: pendingId } }, { onSuccess: () => { setPendingId(""); + setImpact(null); + setConfirmAdd(false); + impactRequestSeq.current += 1; refresh(); }, onError: () => { @@ -296,6 +352,15 @@ function AppPermissionsEditor({ appId }: { appId: number }) { ); }; + const onAdd = () => { + if (pendingId === "" || typeof pendingId !== "number") return; + if (requiresConfirmation && !confirmAdd) { + setConfirmAdd(true); + return; + } + performAdd(); + }; + const onRemove = (permissionId: number) => { removePermission.mutate( { id: appId, permissionId }, @@ -370,13 +435,117 @@ function AppPermissionsEditor({ appId }: { appId: number }) { disabled={ pendingId === "" || addPermission.isPending || - available.length === 0 + available.length === 0 || + impactLoading || + impactError } data-testid="app-permission-add" > - {t("admin.appPermissions.add")} + {addPermission.isPending ? ( + + ) : ( + t("admin.appPermissions.add") + )} + {pendingId !== "" && ( +
+

{t("admin.appPermissions.impactTitle")}

+ {impactLoading ? ( +
+ + {t("admin.appPermissions.impactLoading")} +
+ ) : impactError || !impact ? ( +

+ {t("admin.appPermissions.impactError")} +

+ ) : impact.affectedUserCount === 0 ? ( +

+ {t("admin.appPermissions.impactNone", { + visible: impact.currentlyVisibleUserCount, + })} +

+ ) : ( + <> +

+ {t("admin.appPermissions.impactSummary", { + affected: impact.affectedUserCount, + visible: impact.currentlyVisibleUserCount, + })} +

+ {impact.groups.length > 0 && ( +

+ {t("admin.appPermissions.impactViaGroups", { + groups: impact.groups.map((g) => g.name).join(", "), + })} +

+ )} + + )} +
+ )} + {confirmAdd && impact && impact.affectedUserCount > 0 && ( +
+
+

+ {t("admin.appPermissions.confirmTitle")} +

+

+ {t("admin.appPermissions.confirmBody", { + affected: impact.affectedUserCount, + visible: impact.currentlyVisibleUserCount, + })} +

+ {impact.groups.length > 0 && ( +

+ {t("admin.appPermissions.impactViaGroups", { + groups: impact.groups.map((g) => g.name).join(", "), + })} +

+ )} +
+ + +
+
+
+ )} ); } diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 0a1fddcd..23cdbf7e 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -245,6 +245,27 @@ export interface AppPermissionLink { permissionId: number; } +export interface AppPermissionsImpactBody { + /** Candidate set of permission IDs that would gate this app. Pass an empty array to model "remove every requirement" (i.e. make the app unrestricted). */ + permissionIds: number[]; +} + +export interface AppPermissionImpactGroup { + id: number; + name: string; +} + +export interface AppPermissionsImpact { + /** Distinct non-admin users who currently see the app and would lose visibility under the candidate permission set. Already accounts for users who keep access via `group_apps` group-grants. */ + affectedUserCount: number; + /** Distinct non-admin users who currently see the app under the existing permission set. Useful for showing the warning as a fraction (e.g. "12 of 80 will lose access"). */ + currentlyVisibleUserCount: number; + /** Groups that currently grant this app via `group_apps`. Members of any of these groups keep access regardless of permission changes. */ + groups: AppPermissionImpactGroup[]; + /** True when the candidate set is identical to the current permission set so no change would actually be saved. */ + noChange: boolean; +} + export interface ReplaceRolePermissionsBody { /** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */ permissionIds: number[]; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index b16c221e..96b6c408 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -27,6 +27,8 @@ import type { App, AppDeletionConflict, AppPermissionLink, + AppPermissionsImpact, + AppPermissionsImpactBody, AppSettings, AuditLogList, AuthUser, @@ -1696,6 +1698,108 @@ export const useAddAppPermission = < return useMutation(getAddAppPermissionMutationOptions(options)); }; +/** + * Given a candidate set of permission IDs that would gate this app, +returns how many users would lose visibility of the app (excluding +admins, who always see every app), the count of users who currently +see the app, and the groups that grant the app via `group_apps`. A +member of any returned group always sees the app regardless of +permission changes, so listing them lets the admin see which +populations are protected from the loss. + +Mirrors `POST /roles/{id}/permissions/impact-preview` so the admin +UI can show the same kind of warning before committing the change. + + * @summary Preview the impact of changing the permissions required for an app (admin) + */ +export const getPreviewAppPermissionsImpactUrl = (id: number) => { + return `/api/apps/${id}/permissions/impact-preview`; +}; + +export const previewAppPermissionsImpact = async ( + id: number, + appPermissionsImpactBody: AppPermissionsImpactBody, + options?: RequestInit, +): Promise => { + return customFetch( + getPreviewAppPermissionsImpactUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(appPermissionsImpactBody), + }, + ); +}; + +export const getPreviewAppPermissionsImpactMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["previewAppPermissionsImpact"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return previewAppPermissionsImpact(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type PreviewAppPermissionsImpactMutationResult = NonNullable< + Awaited> +>; +export type PreviewAppPermissionsImpactMutationBody = + BodyType; +export type PreviewAppPermissionsImpactMutationError = ErrorType; + +/** + * @summary Preview the impact of changing the permissions required for an app (admin) + */ +export const usePreviewAppPermissionsImpact = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getPreviewAppPermissionsImpactMutationOptions(options)); +}; + /** * @summary Remove a required permission from this app (admin) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 211f659e..832d08eb 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -470,6 +470,54 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /apps/{id}/permissions/impact-preview: + post: + operationId: previewAppPermissionsImpact + tags: [apps] + summary: Preview the impact of changing the permissions required for an app (admin) + description: | + Given a candidate set of permission IDs that would gate this app, + returns how many users would lose visibility of the app (excluding + admins, who always see every app), the count of users who currently + see the app, and the groups that grant the app via `group_apps`. A + member of any returned group always sees the app regardless of + permission changes, so listing them lets the admin see which + populations are protected from the loss. + + Mirrors `POST /roles/{id}/permissions/impact-preview` so the admin + UI can show the same kind of warning before committing the change. + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AppPermissionsImpactBody" + responses: + "200": + description: Impact preview for the proposed permission set + content: + application/json: + schema: + $ref: "#/components/schemas/AppPermissionsImpact" + "400": + description: Invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: App not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /apps/{id}/permissions/{permissionId}: delete: operationId: removeAppPermission @@ -2819,6 +2867,51 @@ components: - appId - permissionId + AppPermissionsImpactBody: + type: object + properties: + permissionIds: + type: array + items: + type: integer + description: Candidate set of permission IDs that would gate this app. Pass an empty array to model "remove every requirement" (i.e. make the app unrestricted). + required: + - permissionIds + + AppPermissionImpactGroup: + type: object + properties: + id: + type: integer + name: + type: string + required: + - id + - name + + AppPermissionsImpact: + type: object + properties: + affectedUserCount: + type: integer + description: Distinct non-admin users who currently see the app and would lose visibility under the candidate permission set. Already accounts for users who keep access via `group_apps` group-grants. + currentlyVisibleUserCount: + type: integer + description: Distinct non-admin users who currently see the app under the existing permission set. Useful for showing the warning as a fraction (e.g. "12 of 80 will lose access"). + groups: + type: array + items: + $ref: "#/components/schemas/AppPermissionImpactGroup" + description: Groups that currently grant this app via `group_apps`. Members of any of these groups keep access regardless of permission changes. + noChange: + type: boolean + description: True when the candidate set is identical to the current permission set so no change would actually be saved. + required: + - affectedUserCount + - currentlyVisibleUserCount + - groups + - noChange + ReplaceRolePermissionsBody: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 7e5dfd8b..5f1e37f6 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -486,6 +486,60 @@ export const AddAppPermissionBody = zod.object({ .describe("ID of the permission to require for this app."), }); +/** + * Given a candidate set of permission IDs that would gate this app, +returns how many users would lose visibility of the app (excluding +admins, who always see every app), the count of users who currently +see the app, and the groups that grant the app via `group_apps`. A +member of any returned group always sees the app regardless of +permission changes, so listing them lets the admin see which +populations are protected from the loss. + +Mirrors `POST /roles/{id}/permissions/impact-preview` so the admin +UI can show the same kind of warning before committing the change. + + * @summary Preview the impact of changing the permissions required for an app (admin) + */ +export const PreviewAppPermissionsImpactParams = zod.object({ + id: zod.coerce.number(), +}); + +export const PreviewAppPermissionsImpactBody = zod.object({ + permissionIds: zod + .array(zod.number()) + .describe( + 'Candidate set of permission IDs that would gate this app. Pass an empty array to model \"remove every requirement\" (i.e. make the app unrestricted).', + ), +}); + +export const PreviewAppPermissionsImpactResponse = zod.object({ + affectedUserCount: zod + .number() + .describe( + "Distinct non-admin users who currently see the app and would lose visibility under the candidate permission set. Already accounts for users who keep access via `group_apps` group-grants.", + ), + currentlyVisibleUserCount: zod + .number() + .describe( + 'Distinct non-admin users who currently see the app under the existing permission set. Useful for showing the warning as a fraction (e.g. \"12 of 80 will lose access\").', + ), + groups: zod + .array( + zod.object({ + id: zod.number(), + name: zod.string(), + }), + ) + .describe( + "Groups that currently grant this app via `group_apps`. Members of any of these groups keep access regardless of permission changes.", + ), + noChange: zod + .boolean() + .describe( + "True when the candidate set is identical to the current permission set so no change would actually be saved.", + ), +}); + /** * @summary Remove a required permission from this app (admin) */