/** * TASK-0132 — SCIM 2.0 filter grammar (RFC 7644 §3.4.2.2) — the documented * subset. * * filter := attrTest / ( attrTest (" and " | " or ")+ attrTest ) * attrTest := attrValueComparison / present * attrValueComparison := attrPath " eq " value * present := attrPath " pr" * attrPath := "userName" / "active" / "id" / "displayName" * value := STRING-QUOTE / boolean / number (unquoted or quoted) * * Combined expressions are AND-OR at ONE level (no parentheses — the * grammar subset the IdPs we target actually send). Anything outside the * subset throws `ScimError.invalidFilter` (→ 400 scimType=invalidFilter). * * The output is a tiny predicate tree the listing layer evaluates in SQL — * no eval, no string interpolation of user input into the query. */ import { ScimError } from "./scim-errors.js"; export type ScimAttr = "userName" | "active" | "id" | "displayName"; export type ScimValue = string | boolean | number; export interface AttrTest { op: "eq" | "pr"; attr: ScimAttr; value?: ScimValue; } export type ScimFilter = | { kind: "test"; test: AttrTest } | { kind: "and"; left: ScimFilter; right: ScimFilter } | { kind: "or"; left: ScimFilter; right: ScimFilter }; const ATTRS: ScimAttr[] = ["userName", "active", "id", "displayName"]; class Tokenizer { pos = 0; constructor(private readonly src: string) {} private err(where: string): never { throw ScimError.invalidFilter(`Unsupported SCIM filter near "${where}": ${this.src.slice(0, 80)}`); } peek(): string | null { if (this.pos >= this.src.length) return null; return this.src[this.pos]!; } skipWs(): void { while (this.pos < this.src.length && /\s/.test(this.src[this.pos]!)) this.pos += 1; } /** Read a quoted string or a bare token up to a delimiter char. */ token(until: RegExp): string { this.skipWs(); const start = this.pos; if (this.peek() === '"') { this.pos += 1; let out = ""; while (this.pos < this.src.length) { const c = this.src[this.pos]!; if (c === '"') { this.pos += 1; return out; } if (c === "\\" && this.pos + 1 < this.src.length) { out += this.src[this.pos + 1]!; this.pos += 2; continue; } out += c; this.pos += 1; } this.err(`unterminated string at ${start}`); } while (this.pos < this.src.length && !until.test(this.src[this.pos]!)) this.pos += 1; const raw = this.src.slice(start, this.pos); if (!raw.trim()) this.err(`expected value at ${start}`); return raw; } /** Case-insensitive keyword match (SCIM operators are case-insensitive). */ consumeWord(word: string): boolean { this.skipWs(); const rest = this.src.slice(this.pos); if ( rest.toLowerCase().startsWith(word) && (this.src[this.pos + word.length] === undefined || /\s|"/.test(this.src[this.pos + word.length]!)) ) { this.pos += word.length; return true; } return false; } done(): boolean { this.skipWs(); return this.pos >= this.src.length; } } function parseValue(t: Tokenizer): ScimValue { // Unquoted values run to whitespace — `and`/`or` are always whitespace- // delimited, so they can never bleed into a bare token. const raw = t.token(/\s/); const lower = raw.toLowerCase(); if (lower === "true") return true; if (lower === "false") return false; if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw); return raw; } function parseAttr(t: Tokenizer): ScimAttr { const raw = t.token(/[\s"]/); // SCIM attribute names are case-insensitive (RFC 7644) — match against // the canonical camelCase list case-insensitively. const match = ATTRS.find((a) => a.toLowerCase() === raw.toLowerCase()); if (!match) { throw ScimError.invalidFilter(`Unsupported filter attribute "${raw}" (allowed: ${ATTRS.join(", ")})`); } return match; } function parseTest(t: Tokenizer): AttrTest { const attr = parseAttr(t); if (t.consumeWord("eq")) { const value = parseValue(t); if (attr === "active" && typeof value !== "boolean") { throw ScimError.invalidFilter(`"active" takes a boolean (true/false), got ${JSON.stringify(value)}`); } return { op: "eq", attr, value }; } if (t.consumeWord("pr")) { return { op: "pr", attr }; } throw ScimError.invalidFilter(`Expected "eq" or "pr" after "${attr}"`); } function parseTestOrCombined(t: Tokenizer, depth: number): ScimFilter { if (depth > 8) throw ScimError.invalidFilter("Filter expression too deep."); let left: ScimFilter = { kind: "test", test: parseTest(t) }; for (;;) { if (t.consumeWord("and")) { left = { kind: "and", left, right: { kind: "test", test: parseTest(t) } }; } else if (t.consumeWord("or")) { left = { kind: "or", left, right: { kind: "test", test: parseTest(t) } }; } else { return left; } } } /** * Parse a `?filter=` value. Throws ScimError.invalidFilter (400) on any * grammar violation — the caller sends the SCIM error envelope. */ export function parseScimFilter(input: string): ScimFilter { const src = input.trim(); if (!src) throw ScimError.invalidFilter("Empty filter."); const t = new Tokenizer(src); const filter = parseTestOrCombined(t, 0); if (!t.done()) { throw ScimError.invalidFilter(`Trailing characters in filter: "${src.slice(t.pos).trim()}"`); } return filter; } /** * Map a parsed filter to a Prisma `where` fragment for the `User` table — * the ONLY place filter input touches a query (via Prisma's parameterized * operators; the values are typed, not interpolated). * * userName → email (SCIM userName IS the email here — see toEmail) * active → status (true = ACTIVE|INVITED, false = SUSPENDED) * id → id * displayName → displayName */ export function filterToWhere(filter: ScimFilter): Record { const testWhere = (t: AttrTest): Record => { if (t.op === "pr") { switch (t.attr) { case "id": return { id: { not: null } }; case "active": return {}; // every user has an "active" state case "userName": return { email: { not: null } }; case "displayName": return { displayName: { not: null } }; } } switch (t.attr) { case "userName": return { email: { equals: String(t.value), mode: "insensitive" } }; case "active": return t.value === true ? { status: { in: ["ACTIVE", "INVITED"] } } : { status: "SUSPENDED" }; case "id": return { id: { equals: String(t.value) } }; case "displayName": return { displayName: { equals: String(t.value), mode: "insensitive" } }; } }; const walk = (f: ScimFilter): Record => { if (f.kind === "test") return testWhere(f.test); if (f.kind === "and") return { AND: [walk(f.left), walk(f.right)] }; return { OR: [walk(f.left), walk(f.right)] }; }; return walk(filter); }