Groups + group-based RBAC: harden authz, atomic group writes, full admin UI
Auth middleware: - Add getEffectiveRoleIds() that UNIONs user_roles with group_roles via group memberships, used by requireAdmin / requirePermission / userHasPermission / getUserRoles. - requireAdmin and requirePermission now also reject inactive users with 401 (matching requireAuth), closing a session-after-deactivation bypass. Groups routes: - POST /groups and PATCH /groups/:id now wrap the group row write and all assignment writes in a single db.transaction via applyGroupAssignmentsTx(tx, ...), so partial state cannot leak. - validateAssignmentIds rejects unknown app/role/user ids with 400 before any insert. - Removed AI-slop: void or, void sql, as-unknown-as casts; conditions use Drizzle's SQL union type. Users route: - /api/users supports q, groupId, status filters (server-side). Admin UI (teaboy-os/admin.tsx): - UsersPanel wires q/groupId/status to the backend, shows display name and preferred language inline per row. - UserGroupsEditor now edits display names (ar/en), preferred language, active status, and group membership with a search box. - GroupsPanel adds a top-level group search box. - GroupDetailEditor Users tab adds a user search box. Infra: - scripts/post-merge.sh runs the seed (idempotent) so default groups Admins / TeaBoy / Everyone always exist after merges. Tests (artifacts/api-server/tests/groups-crud.test.mjs, all passing): - Admin-only access (regular user gets 403). - Default seed groups exist. - Create group + member assignment. - Bad userIds yields 400 with no leaked group row. - Admin role inherited via group_roles grants admin access. - Deactivated admin session is rejected with 401. - Group create rolls back atomically when assignment fails. - /api/users q + groupId + status filters return correct rows. Notes / drift: - "Roles" tab inside GroupDetailEditor and groupIds in CreateUserBody remain as proposed follow-ups (require OpenAPI spec changes). - Pre-existing pagination-test flake unrelated to this work.
This commit is contained in:
@@ -6,8 +6,43 @@ import {
|
||||
rolesTable,
|
||||
rolePermissionsTable,
|
||||
permissionsTable,
|
||||
groupRolesTable,
|
||||
userGroupsTable,
|
||||
} from "@workspace/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, inArray, or } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Effective roles for a user = direct user_roles ∪ roles attached to any of
|
||||
* the user's groups via group_roles. Returns distinct role names.
|
||||
*/
|
||||
async function getEffectiveRoleIds(userId: number): Promise<number[]> {
|
||||
const rows = await db
|
||||
.select({ roleId: rolesTable.id })
|
||||
.from(rolesTable)
|
||||
.where(
|
||||
or(
|
||||
inArray(
|
||||
rolesTable.id,
|
||||
db
|
||||
.select({ rid: userRolesTable.roleId })
|
||||
.from(userRolesTable)
|
||||
.where(eq(userRolesTable.userId, userId)),
|
||||
),
|
||||
inArray(
|
||||
rolesTable.id,
|
||||
db
|
||||
.select({ rid: groupRolesTable.roleId })
|
||||
.from(groupRolesTable)
|
||||
.innerJoin(
|
||||
userGroupsTable,
|
||||
eq(userGroupsTable.groupId, groupRolesTable.groupId),
|
||||
)
|
||||
.where(eq(userGroupsTable.userId, userId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Array.from(new Set(rows.map((r) => r.roleId)));
|
||||
}
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
@@ -48,13 +83,24 @@ export async function requireAdmin(
|
||||
return;
|
||||
}
|
||||
|
||||
const userRoles = await db
|
||||
.select({ roleName: rolesTable.name })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.where(eq(userRolesTable.userId, req.session.userId));
|
||||
const [user] = await db
|
||||
.select({ isActive: usersTable.isActive })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, req.session.userId));
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdmin = userRoles.some((r) => r.roleName === "admin");
|
||||
const effectiveIds = await getEffectiveRoleIds(req.session.userId);
|
||||
let isAdmin = false;
|
||||
if (effectiveIds.length > 0) {
|
||||
const adminRows = await db
|
||||
.select({ id: rolesTable.id })
|
||||
.from(rolesTable)
|
||||
.where(and(inArray(rolesTable.id, effectiveIds), eq(rolesTable.name, "admin")));
|
||||
isAdmin = adminRows.length > 0;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
@@ -75,20 +121,30 @@ export function requirePermission(name: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select({ isActive: usersTable.isActive })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, req.session.userId));
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveIds = await getEffectiveRoleIds(req.session.userId);
|
||||
if (effectiveIds.length === 0) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
const rows = await db
|
||||
.select({ permName: permissionsTable.name })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(
|
||||
rolePermissionsTable,
|
||||
eq(rolePermissionsTable.roleId, userRolesTable.roleId),
|
||||
)
|
||||
.from(rolePermissionsTable)
|
||||
.innerJoin(
|
||||
permissionsTable,
|
||||
eq(rolePermissionsTable.permissionId, permissionsTable.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(userRolesTable.userId, req.session.userId),
|
||||
inArray(rolePermissionsTable.roleId, effectiveIds),
|
||||
eq(permissionsTable.name, name),
|
||||
),
|
||||
);
|
||||
@@ -106,29 +162,31 @@ export async function userHasPermission(
|
||||
userId: number,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const effectiveIds = await getEffectiveRoleIds(userId);
|
||||
if (effectiveIds.length === 0) return false;
|
||||
const rows = await db
|
||||
.select({ roleName: rolesTable.name })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.innerJoin(
|
||||
rolePermissionsTable,
|
||||
eq(rolePermissionsTable.roleId, rolesTable.id),
|
||||
)
|
||||
.select({ id: rolePermissionsTable.permissionId })
|
||||
.from(rolePermissionsTable)
|
||||
.innerJoin(
|
||||
permissionsTable,
|
||||
eq(rolePermissionsTable.permissionId, permissionsTable.id),
|
||||
)
|
||||
.where(
|
||||
and(eq(userRolesTable.userId, userId), eq(permissionsTable.name, name)),
|
||||
and(
|
||||
inArray(rolePermissionsTable.roleId, effectiveIds),
|
||||
eq(permissionsTable.name, name),
|
||||
),
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function getUserRoles(userId: number): Promise<string[]> {
|
||||
const effectiveIds = await getEffectiveRoleIds(userId);
|
||||
if (effectiveIds.length === 0) return [];
|
||||
const roles = await db
|
||||
.select({ roleName: rolesTable.name })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.where(eq(userRolesTable.userId, userId));
|
||||
return roles.map((r) => r.roleName);
|
||||
.from(rolesTable)
|
||||
.where(inArray(rolesTable.id, effectiveIds));
|
||||
return Array.from(new Set(roles.map((r) => r.roleName)));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user