UNPKG

@zingage/postgres-multi-tenant-ids

Version:

PostgreSQL IDs for secure multi-tenant applications

70 lines (69 loc) 3.59 kB
import { parse as uuidParse, stringify as uuidStringify } from "uuid"; import { instantiateTaggedType } from "type-party/runtime/tagged-types.js"; import { assertUnreachable, interposeVersionAndVariant, isUUIDV8, makeUUIDBuffer, } from "../helpers/utils.js"; export function makeMakeScopedIdBound(tenantScopedEntityTypeConfigs) { return (tenantId, entityType, date = new Date()) => { return makeScopedId(tenantScopedEntityTypeConfigs, tenantId, entityType, date); }; } export function makeScopedId(tenantScopedEntityTypeConfigs, tenantId, entityType, date = new Date()) { const { insertRate, entityTypeHint } = tenantScopedEntityTypeConfigs[entityType]; const ownedIdBuffer = makeUUIDBuffer(); const ownedIdView = new DataView(ownedIdBuffer); const ownedIdBytes = new Uint8Array(ownedIdBuffer); // If insert rate is very low, we can just use randomness at the end; // otherwise, we'll figure out later what we're doing. if (insertRate !== "VERY_LOW") { assertUnreachable(insertRate, "Only very low insert rate is supported"); } // Read prefix for owning business id. // NB: we use DataView because it'll read big endian. const businessIdBytes = uuidParse(tenantId); const trailing32Bits = new DataView(businessIdBytes.buffer).getUint32(12); // Add a leading 1 bit for the owned id code, then the business id prefix. ownedIdView.setUint32(0, Number((1n << 31n) | BigInt(trailing32Bits)), false); // Add remaining bits code + timestamp + entity type code. This is 55 bits // total (with a type-level test to check our assumption about the remaining // bit code length). So we also add 9 bits of randomness to fill the uint64. const remainingBitsCode = "000"; // milliseconds since unix epoch, padded to 42 bits, which'll overflow // sufficiently far into the future. const msTimestampString = date.valueOf().toString(2).padStart(42, "0"); const randomness = crypto.getRandomValues(new Uint8Array(6)); ownedIdView.setBigUint64(4, BigInt(`0b${remainingBitsCode}${msTimestampString}${entityTypeHint}${randomness[0].toString(2).padStart(8, "0")}${randomness[1] % 2}`), false); // Add the remaining randomness. ownedIdView.setUint8(12, randomness[2]); ownedIdView.setUint8(13, randomness[3]); ownedIdView.setUint8(14, randomness[4]); ownedIdView.setUint8(15, randomness[5]); interposeVersionAndVariant(ownedIdBuffer); return instantiateTaggedType(uuidStringify(ownedIdBytes)); } /** * NB: filling in T is just a convenient way to do a cast if you (think you) * know what type of id you're getting from the outside world. */ export function isScopedId(id) { return parseInt(id[0], 16) >= 8; // validate leading bit is a 1 } /** * NB: filling in T is just a convenient way to do a cast if you (think you) * know what type of id you're getting from the outside world. */ export function stringIsScopedId(id) { return isUUIDV8(id) && isScopedId(id); } export function getTenantShortIdFromScopedId(id) { const idBytes = uuidParse(id); const leading32Bits = new DataView(idBytes.buffer).getUint32(0); // Zero out the leading bit, since it's the owned id indicator, not part of // the short id. return instantiateTaggedType(leading32Bits & 0x7fffffff); } export function scopedIdsBelongToSameTenant(ids) { if (ids.length === 0) { return true; } const firstIdTenantShortId = getTenantShortIdFromScopedId(ids[0]); return ids.every((id) => getTenantShortIdFromScopedId(id) === firstIdTenantShortId); }