Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/auth/people-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { type SQL, and, eq, exists, gt, ilike, or, sql } from "drizzle-orm";
import type { db } from "../db/index";
import { account, identityEvidence, passkey, user, userRole } from "../db/schema";
import { env } from "../env";
import { matchesSearch } from "../lib/search";
import { groupMembers } from "./group-listing";
import type { RbacCatalog } from "./rbac";
import { type GroupTrait, type MembershipSource, type UserTrait, USER_TRAITS } from "./users";

type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];

// The same trust boundary `readIdentitySubject` applies: evidence counts only from the
// issuer each provider is expected to use.
const TELEGRAM_ISSUER = "https://oauth.telegram.org";
const POLIMI_EMAIL_ISSUER = "https://mail.polimi.it";

function entraIssuer() {
return env.PN_ENTRA_TENANT_ID
? `https://login.microsoftonline.com/${env.PN_ENTRA_TENANT_ID}/v2.0`
: undefined;
}

type GroupListing = { source: MembershipSource; members: Set<string> | null };

/**
* Who is in each PoliNetwork group right now. Checking people one by one would page
* through the group once per person, so the directory lists each group once instead. When
* Graph cannot answer, it falls back to what each person's last PoliNetwork sign-in
* recorded, and says so.
*/
export async function groupListings(): Promise<Record<GroupTrait, GroupListing>> {
const listing = async (groupId: string | undefined): Promise<GroupListing> => {
if (!groupId || !entraIssuer()) return { source: "unconfigured", members: null };
const members = await groupMembers(groupId);
return members ? { source: "live", members } : { source: "recorded", members: null };
};
const [socio, direttivo] = await Promise.all([
listing(env.PN_ENTRA_MEMBER_GROUP_ID),
listing(env.PN_ENTRA_DIRETTIVO_GROUP_ID),
]);
return { socio, direttivo };
}

/** Whether the person owns an account matching `where`. */
function ownsAccount(transaction: Transaction, where: SQL | undefined) {
return exists(
transaction
.select({ found: sql`1` })
.from(account)
.where(and(eq(account.userId, user.id), where)),
);
}

/** Whether the person owns an account whose stored evidence matches `where`. */
function ownsEvidence(transaction: Transaction, where: SQL | undefined) {
return exists(
transaction
.select({ found: sql`1` })
.from(account)
.innerJoin(
identityEvidence,
and(
eq(account.issuer, identityEvidence.issuer),
eq(account.accountId, identityEvidence.subject),
eq(account.providerId, identityEvidence.providerId),
),
)
.where(and(eq(account.userId, user.id), where)),
);
}

function groupCondition(transaction: Transaction, state: GroupTrait, listing: GroupListing): SQL {
const issuer = entraIssuer();
if (listing.source === "unconfigured" || !issuer) return sql`false`;
const entra = and(eq(account.providerId, "pn-entra"), eq(account.issuer, issuer));
if (listing.members) {
if (!listing.members.size) return sql`false`;
// One array parameter, however large the group is.
const members = sql.param([...listing.members]);
return ownsEvidence(
transaction,
and(entra, sql`${identityEvidence.externalId} = any(${members}::text[])`),
);
}
return ownsEvidence(transaction, and(entra, sql`${state} = any(${identityEvidence.states})`));
}

/**
* One boolean expression per trait, used both to filter and to report each row, so what
* the list shows can never disagree with what it was filtered by.
*/
export function traitConditions(
transaction: Transaction,
groups: Record<GroupTrait, GroupListing>,
now: Date,
): Record<UserTrait, SQL> {
return {
socio: groupCondition(transaction, "socio", groups.socio),
direttivo: groupCondition(transaction, "direttivo", groups.direttivo),
student: ownsEvidence(
transaction,
and(
eq(account.providerId, "polimi-email"),
eq(account.issuer, POLIMI_EMAIL_ISSUER),
sql`'student' = any(${identityEvidence.states})`,
gt(identityEvidence.validUntil, now),
),
),
telegram: ownsAccount(
transaction,
and(eq(account.providerId, "telegram"), eq(account.issuer, TELEGRAM_ISSUER)),
),
google: ownsAccount(transaction, eq(account.providerId, "google")),
polinetwork: ownsAccount(transaction, eq(account.providerId, "pn-entra")),
passkey: exists(
transaction
.select({ found: sql`1` })
.from(passkey)
.where(eq(passkey.userId, user.id)),
),
};
}

export function searchCondition(
transaction: Transaction,
query: string,
catalog: RbacCatalog,
traits?: Record<UserTrait, SQL>,
canReadRoles = false,
) {
query = query.trim();
const escaped = query.replace(/[%_\\]/g, (match) => `\\${match}`);
const term = `%${escaped}%`;
return or(
...(traits
? USER_TRAITS.filter((trait) =>
matchesSearch(query, [trait.key, trait.label, trait.description]),
).map((trait) => traits[trait.key])
: []),
...(canReadRoles
? catalog.roles
.filter(
(role) =>
!role.managed && matchesSearch(query, [role.key, role.name, role.description]),
)
.map((role) =>
exists(
transaction
.select({ found: sql`1` })
.from(userRole)
.where(and(eq(userRole.userId, user.id), eq(userRole.roleId, role.id))),
),
)
: []),
ilike(user.name, term),
ilike(user.email, term),
eq(user.id, query),
ownsAccount(
transaction,
and(
eq(account.providerId, "polimi-email"),
eq(account.issuer, POLIMI_EMAIL_ISSUER),
ilike(account.accountId, term),
),
),
ownsEvidence(
transaction,
and(eq(account.providerId, "pn-entra"), ilike(identityEvidence.email, term)),
),
ownsEvidence(
transaction,
and(
eq(account.providerId, "telegram"),
eq(account.issuer, TELEGRAM_ISSUER),
or(
ilike(identityEvidence.telegramId, term),
// Usernames are case-insensitive on Telegram; the @ is optional.
ilike(identityEvidence.telegramUsername, `%${escaped.replace(/^@/, "")}%`),
),
),
),
);
}
5 changes: 4 additions & 1 deletion src/auth/rbac-security.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ vi.mock("../env", () => {
},
};
});
vi.mock("./membership", () => ({ checkEntraGroupMember: mocks.graph }));
vi.mock("./membership", () => ({
checkEntraGroupMember: mocks.graph,
listEntraGroupMembers: vi.fn().mockResolvedValue(null),
}));
// Only session authentication is substituted; routes, authorization, evidence, SQL and
// transactions are real. The separate identity HTTP suite exercises signed session cookies.
vi.mock("./index", () => ({
Expand Down
40 changes: 12 additions & 28 deletions src/auth/rbac-store.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { groupListings, traitConditions, searchCondition } from "./people-search";
import type { FieldErrors } from "@/lib/action-error";
import { contactEmails } from "./contact-email";
import { mayDelegateMutation } from "./rbac-delegation";
import { logAuthorizationDenial } from "./denial-log";
import { readIdentitySubject, refreshIdentityMembership } from "./identity-subject";
import type { IdentityClaims } from "./policy";
import { randomUUID } from "node:crypto";
import { and, count, eq, exists, gt, ilike, or, sql } from "drizzle-orm";
import { and, count, eq, gt, sql } from "drizzle-orm";
import { db } from "../db/index";
import { authorizationMutationLock } from "../db/security-lock";
import {
account,
identityEvidence,
rbacAuditEvent,
permission,
permissionImplication,
Expand Down Expand Up @@ -501,7 +500,10 @@ export async function searchUsers(
}
requireRole(catalog, roleId);
}
const term = `%${query.trim().replace(/[%_\\]/g, (match) => `\\${match}`)}%`;
const traits =
query.trim() && access.permissions.includes("idp:users:read")
? traitConditions(transaction, await groupListings(), new Date())
: undefined;
const people = await transaction
.select({
id: user.id,
Expand All @@ -515,30 +517,12 @@ export async function searchUsers(
.from(user)
.where(
query.trim()
? or(
ilike(user.name, term),
ilike(user.email, term),
// The PoliNetwork address saved at sign-in, since the stored email is a placeholder.
exists(
transaction
.select({ found: sql`1` })
.from(account)
.innerJoin(
identityEvidence,
and(
eq(account.issuer, identityEvidence.issuer),
eq(account.accountId, identityEvidence.subject),
eq(account.providerId, identityEvidence.providerId),
),
)
.where(
and(
eq(account.userId, user.id),
eq(account.providerId, "pn-entra"),
ilike(identityEvidence.email, term),
),
),
),
? searchCondition(
transaction,
query,
catalog,
traits,
access.permissions.includes("idp:roles:read"),
)
: undefined,
)
Expand Down
38 changes: 36 additions & 2 deletions src/auth/user-directory.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("user directory with Postgr

async function evidence(issuer, subject, providerId, fields) {
await pool.query(
`INSERT INTO identity_evidence (issuer, subject, provider_id, external_id, states, valid_until, telegram_id, email)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
`INSERT INTO identity_evidence (issuer, subject, provider_id, external_id, states, valid_until, telegram_id, email, telegram_username)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
issuer,
subject,
Expand All @@ -113,6 +113,7 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("user directory with Postgr
fields.validUntil ?? new Date(Date.now() + 86_400_000),
fields.telegramId ?? null,
fields.email ?? null,
fields.telegramUsername ?? null,
],
);
}
Expand Down Expand Up @@ -145,6 +146,7 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("user directory with Postgr
await account(ada, "telegram", "https://oauth.telegram.org", `${ada}-telegram`);
await evidence("https://oauth.telegram.org", `${ada}-telegram`, "telegram", {
telegramId: "4242424242",
telegramUsername: "Ada_directory_test",
});
const adaMail = `${ada}@mail.polimi.it`;
await account(ada, "polimi-email", "https://mail.polimi.it", adaMail);
Expand Down Expand Up @@ -263,6 +265,38 @@ describe.skipIf(!process.env.RBAC_TEST_DATABASE_URL)("user directory with Postgr
expect(names(await listUsers(root, { q: `${tag.slice(0, -1)}_` }))).toEqual([]);
});

it("searches Telegram fragments consistently in People and role assignment", async () => {
for (const q of ["@ADA_DIRECTORY", "ada_directory", "424242", `${bruno}@mail.polimi`]) {
const expected = q.includes("polimi") ? bruno : ada;
expect(names(await listUsers(root, { q }))).toEqual([expected]);
expect((await searchUsers(root, q)).map((person) => person.id)).toEqual([expected]);
}
expect(names(await listUsers(root, { q: "@ada_directory%" }))).toEqual([]);
expect(await searchUsers(root, "@ada_directory%")).toEqual([]);
expect(await searchUsers(root, "515151")).toEqual([]);
});

it("searches the same traits and roles as the filters, combined with explicit filters", async () => {
for (const [q, expected] of [
["Student", ada],
["Passkey", bruno],
["Google", bruno],
[moderator.key, bruno],
]) {
expect(names(await listUsers(root, { q }))).toEqual([expected]);
expect((await searchUsers(root, q)).map((person) => person.id)).toEqual([expected]);
}
expect(names(await listUsers(root, { q: "Google", passkey: "no" }))).toEqual([]);
const reader = await delegate(["idp:users:read", "idp:people:read"]);
expect(names(await listUsers(reader, { q: moderator.key }))).toEqual([]);
expect(await searchUsers(reader, moderator.key)).toEqual([]);
const searcher = await delegate(["idp:people:read"]);
expect(await searchUsers(searcher, "Passkey")).toEqual([]);
expect((await searchUsers(searcher, "@ADA_DIRECTORY")).map((person) => person.id)).toEqual([
ada,
]);
});

it("keeps people without a known email visible, searchable, and assignable", async () => {
const search = await call(searchPeople, root, { query: ordinary, roleId: moderator.id });
expect(search.status).toBe(200);
Expand Down
Loading
Loading