Add app-permissions impact preview before tightening an app's gate
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.
This commit is contained in:
@@ -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<void
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/apps/:id/permissions/impact-preview",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
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<number>(candidate as number[]));
|
||||
const candidateSet = new Set<number>(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<number>(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<number>(
|
||||
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<number>();
|
||||
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<Set<number>> => {
|
||||
const out = new Set<number>();
|
||||
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<Set<number>> => {
|
||||
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<number>(allNonAdminUserIds);
|
||||
};
|
||||
|
||||
const computeVisible = async (perms: number[]): Promise<Set<number>> => {
|
||||
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<void> => {
|
||||
const params = GetAppParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user