From b507b33cad423bef6d832b7039f926a23be1e21e Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Thu, 30 Apr 2026 18:03:08 +0000 Subject: [PATCH] Add app-permissions impact preview before tightening an app's gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing role-permissions impact preview UX for app permissions. Admins now see how many currently-visible users would lose access before they add a permission requirement to an app, plus the groups (via group_apps) that offset the loss because their members keep access regardless. Backend - New endpoint POST /api/apps/:id/permissions/impact-preview in artifacts/api-server/src/routes/apps.ts. Implements the same OR semantics as getVisibleAppsForUser: a user "sees" an app if they hold ANY required permission (direct or via a group role) OR they belong to a group granted the app via group_apps. Admins are excluded from counts since they always see every app. Short-circuits with noChange:true when the candidate set equals the current set. - OpenAPI schema (lib/api-spec/openapi.yaml): adds the path, AppPermissionsImpactBody, AppPermissionImpactGroup, AppPermissionsImpact. Regenerated lib/api-client-react bindings. Frontend - AppPermissionsEditor (artifacts/tx-os/src/pages/admin.tsx): debounced (350ms) cancel-safe preview when a pending permission is selected, warning banner with affected/visible counts and offsetting groups, and a confirmation dialog when affectedUserCount > 0. Add button is disabled while the preview is loading or errored to keep the warning trustworthy. - i18n keys added to en.json and ar.json under admin.appPermissions.{impactTitle, impactLoading, impactError, impactNone, impactSummary, impactViaGroups, confirmTitle, confirmBody, confirmAction}. Tests - artifacts/api-server/tests/app-permissions-impact.test.mjs: 7 tests covering noChange short-circuit, unrestricted-app tightening, candidate that keeps an existing permission, group_apps offset, unknown app (404), invalid payload (400), and admin-only enforcement. All 18 app-permissions tests pass. - E2E flow verified via runTest: admin login → /admin → Apps → edit app → select permission → preview banner appears → Add → confirm dialog → cancel without writing. Out-of-scope (filed as follow-ups #228 and #229): listing the specific affected user IDs in the preview, and warning when REMOVING a permission broadens access. No deviations from the task spec. Replit-Task-Id: 8b2ff9ea-f95e-4bdb-b268-95b5d17154ca --- artifacts/api-server/src/routes/apps.ts | 185 ++++++++ .../tests/app-permissions-impact.test.mjs | 441 ++++++++++++++++++ artifacts/tx-os/public/opengraph.jpg | Bin 24607 -> 25953 bytes artifacts/tx-os/src/locales/ar.json | 11 +- artifacts/tx-os/src/locales/en.json | 11 +- artifacts/tx-os/src/pages/admin.tsx | 175 ++++++- .../src/generated/api.schemas.ts | 21 + lib/api-client-react/src/generated/api.ts | 104 +++++ lib/api-spec/openapi.yaml | 93 ++++ lib/api-zod/src/generated/api.ts | 54 +++ 10 files changed, 1090 insertions(+), 5 deletions(-) create mode 100644 artifacts/api-server/tests/app-permissions-impact.test.mjs 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 5792704d222a16b749a1331a151b37cdc11f2a5e..d9336aa63b8973dc19fec85be14095bf504753cd 100644 GIT binary patch literal 25953 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 X^Ed!nD{DCp%gZ@bBU_*F`#=8&ZaJtj 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) */