// Immediate blind-index nulling after a subject erase (#818). // // After kms.eraseKey the ciphertext is unreadable, but the deterministic // bidx column would stay matchable until the next write/rebuild — a // linkage window ("does any row hold value X"). This sweep closes it right // away: the ciphertext names its subject inline // (kumiko-pii:v1::...), so a LIKE-prefix match finds exactly // the erased subject's rows — one UPDATE per lookupable field. // // Rows the forget run deletes/anonymizes via the executor anyway get their // bidx recomputed automatically there; this sweep covers the rows left // behind (foreign entities with userOwned fields). import { collectLookupableFields } from "../crypto/blind-index"; import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern"; import type { FeatureDefinition } from "../engine/types"; import { toSnakeCase } from "../utils/case"; import type { DbRunner } from "./connection"; import { resolveTableName } from "./entity-table-meta"; import { executeRawQuery } from "./queries/raw-sql"; export async function nullBlindIndexesForSubject( db: DbRunner, features: ReadonlyMap, subjectKey: string, ): Promise { const likePattern = subjectCiphertextLikePattern(subjectKey); for (const feature of features.values()) { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { const lookupable = collectLookupableFields(entity); if (lookupable.length === 0) continue; // No featureName prefix — the dispatcher builds entity tables without // one (buildEntityTable with no featureName option), the sweep has to // hit the same names. const tableName = resolveTableName(entityName, entity, undefined); for (const fieldName of lookupable) { const snake = toSnakeCase(fieldName); await executeRawQuery( db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern], ); } } } } // Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a // "user"-kind subject id is often not a real user (share-token recipient, // email subscriber, ...) — those entities self-own their PII (`personal: // "self"`, i.e. their own row id IS the subject) and carry a real tenant_id, // unlike read_users (systemStream, tenant_id always SYSTEM_TENANT_ID). This // checks whether the subject row lives in the given tenant, so a tenant- // scoped DPO can still forget subjects it truly owns without needing a // tenant-membership row (which only exists for real users). export async function subjectRowExistsInTenant( db: DbRunner, features: ReadonlyMap, subjectId: string, tenantId: string, ): Promise { for (const feature of features.values()) { for (const [entityName, entity] of Object.entries(feature.entities ?? {})) { const hasSelfPiiField = Object.values(entity.fields).some( (field) => "pii" in field && field.pii === true, ); if (!hasSelfPiiField) continue; const tableName = resolveTableName(entityName, entity, undefined); try { const rows = await executeRawQuery( db, `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`, [subjectId, tenantId], ); if (rows.length > 0) return true; } catch { // Table missing, id-type mismatch, ... — not evidence the subject is // owned here. Fail-closed must come from an honest "not found" across // every entity, never from a query blowing up on one of them. } } } return false; }