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:
riyadhafraa
2026-04-22 08:40:38 +00:00
parent f5273af19f
commit bf0768948e
6 changed files with 603 additions and 134 deletions
+83 -25
View File
@@ -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)));
}