285 lines
9.2 KiB
TypeScript
285 lines
9.2 KiB
TypeScript
import { createHmac, randomUUID } from "node:crypto";
|
|
|
|
import { createFixedWindowRateLimiter, MemoryAdapter, type SharedStore } from "@hivegrid/shared-store";
|
|
|
|
import { getSharedStore } from "~/shared-store.server";
|
|
import { createScopedLogger } from "~/utils/logger.server";
|
|
|
|
export type GdprExportDownloadGateScope = "portal" | "privacy";
|
|
|
|
export type GdprExportDownloadGateLease = {
|
|
/**
|
|
* Releases both distributed lease keys when this caller still owns them.
|
|
* Store failures are logged and left to the bounded lease TTL.
|
|
*/
|
|
release(): Promise<void>;
|
|
};
|
|
|
|
export type AcquireGdprExportDownloadGateInput = {
|
|
readonly scope: GdprExportDownloadGateScope;
|
|
readonly tenantId: string;
|
|
readonly principalId: string;
|
|
readonly artifactId: string;
|
|
readonly secret: string;
|
|
};
|
|
|
|
export const GDPR_EXPORT_DOWNLOAD_LEASE_TTL_SECONDS = 90;
|
|
export const GDPR_EXPORT_DOWNLOAD_LEASE_RENEW_SECONDS = 30;
|
|
export const GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_MAX = 6;
|
|
export const GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_WINDOW_SECONDS = 5 * 60;
|
|
|
|
const ACQUIRE_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT = `
|
|
local owner = ARGV[1]
|
|
local ttl = tonumber(ARGV[2])
|
|
|
|
if redis.call('GET', KEYS[1]) then
|
|
return { 0, 1 }
|
|
end
|
|
if redis.call('GET', KEYS[2]) then
|
|
return { 0, 2 }
|
|
end
|
|
|
|
redis.call('SET', KEYS[1], owner, 'EX', ttl)
|
|
redis.call('SET', KEYS[2], owner, 'EX', ttl)
|
|
return { 1, 0 }
|
|
`;
|
|
|
|
const RELEASE_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT = `
|
|
local owner = ARGV[1]
|
|
local released = 0
|
|
|
|
if redis.call('GET', KEYS[1]) == owner then
|
|
released = released + redis.call('DEL', KEYS[1])
|
|
end
|
|
if redis.call('GET', KEYS[2]) == owner then
|
|
released = released + redis.call('DEL', KEYS[2])
|
|
end
|
|
return released
|
|
`;
|
|
|
|
const RENEW_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT = `
|
|
local owner = ARGV[1]
|
|
local ttl = tonumber(ARGV[2])
|
|
|
|
if redis.call('GET', KEYS[1]) ~= owner then
|
|
return 0
|
|
end
|
|
if redis.call('GET', KEYS[2]) ~= owner then
|
|
return 0
|
|
end
|
|
|
|
redis.call('EXPIRE', KEYS[1], ttl)
|
|
redis.call('EXPIRE', KEYS[2], ttl)
|
|
return 1
|
|
`;
|
|
|
|
MemoryAdapter.registerScript(ACQUIRE_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT, async (adapter, keys, args) => {
|
|
const owner = args[0] ?? "";
|
|
const ttlSeconds = Number.parseInt(args[1] ?? "0", 10);
|
|
if (await adapter.get(keys[0])) return [0, 1];
|
|
if (await adapter.get(keys[1])) return [0, 2];
|
|
await adapter.set(keys[0], owner, { ttlSeconds });
|
|
await adapter.set(keys[1], owner, { ttlSeconds });
|
|
return [1, 0];
|
|
});
|
|
|
|
MemoryAdapter.registerScript(RELEASE_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT, async (adapter, keys, args) => {
|
|
const owner = args[0] ?? "";
|
|
let released = 0;
|
|
if ((await adapter.get(keys[0])) === owner) released += await adapter.del(keys[0]);
|
|
if ((await adapter.get(keys[1])) === owner) released += await adapter.del(keys[1]);
|
|
return released;
|
|
});
|
|
|
|
MemoryAdapter.registerScript(RENEW_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT, async (adapter, keys, args) => {
|
|
const owner = args[0] ?? "";
|
|
const ttlSeconds = Number.parseInt(args[1] ?? "0", 10);
|
|
if ((await adapter.get(keys[0])) !== owner || (await adapter.get(keys[1])) !== owner) return 0;
|
|
await adapter.expire(keys[0], ttlSeconds);
|
|
await adapter.expire(keys[1], ttlSeconds);
|
|
return 1;
|
|
});
|
|
|
|
const logger = createScopedLogger("gdpr-export-download-gate");
|
|
let storeFactory: () => SharedStore = () => getSharedStore();
|
|
|
|
/**
|
|
* Reserves one distributed buffered download for both the authenticated
|
|
* principal and the artifact. Redis keys contain only HMAC digests. Any
|
|
* validation/store failure denies the read, so a lost shared store cannot
|
|
* turn into an unbounded per-replica memory path.
|
|
*/
|
|
export async function acquireGdprExportDownloadGate(
|
|
input: AcquireGdprExportDownloadGateInput,
|
|
): Promise<GdprExportDownloadGateLease | null> {
|
|
try {
|
|
const keys = gdprExportDownloadGateKeys(input);
|
|
const store = storeFactory();
|
|
const rateAllowed = await reserveRateLimit(store, input.scope, keys);
|
|
if (!rateAllowed) return null;
|
|
const owner = randomUUID();
|
|
const result = (await store.eval(ACQUIRE_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT, keys, [
|
|
owner,
|
|
String(GDPR_EXPORT_DOWNLOAD_LEASE_TTL_SECONDS),
|
|
])) as [number | string, number | string] | undefined;
|
|
if (Number(result?.[0]) !== 1) return null;
|
|
|
|
let releasePromise: Promise<void> | undefined;
|
|
const renewalTimer = setInterval(() => {
|
|
void renewLease(store, keys, owner, input.scope);
|
|
}, GDPR_EXPORT_DOWNLOAD_LEASE_RENEW_SECONDS * 1_000);
|
|
renewalTimer.unref?.();
|
|
return {
|
|
release() {
|
|
if (!releasePromise) {
|
|
clearInterval(renewalTimer);
|
|
releasePromise = releaseLease(store, keys, owner, input.scope);
|
|
}
|
|
return releasePromise;
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logGateFailure("acquire", input.scope, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function reserveRateLimit(
|
|
store: SharedStore,
|
|
scope: GdprExportDownloadGateScope,
|
|
keys: [string, string],
|
|
): Promise<boolean> {
|
|
const principal = await createFixedWindowRateLimiter({
|
|
store,
|
|
prefix: `gdpr-export-download-v1:${scope}:principal`,
|
|
max: GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_MAX,
|
|
windowSeconds: GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_WINDOW_SECONDS,
|
|
}).consume(keys[0]);
|
|
if (!principal.allowed) return false;
|
|
const artifact = await createFixedWindowRateLimiter({
|
|
store,
|
|
prefix: `gdpr-export-download-v1:${scope}:artifact`,
|
|
max: GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_MAX,
|
|
windowSeconds: GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_WINDOW_SECONDS,
|
|
}).consume(keys[1]);
|
|
return artifact.allowed;
|
|
}
|
|
|
|
/**
|
|
* Keeps the lease until the HTTP runtime has consumed or cancelled the body,
|
|
* rather than releasing it as soon as the fully buffered Response is built.
|
|
*/
|
|
export function holdGdprExportDownloadGateUntilResponseFinished(
|
|
response: Response,
|
|
lease: GdprExportDownloadGateLease,
|
|
): Response {
|
|
if (!response.body || response.bodyUsed) throw new Error("GDPR export download response body is unavailable.");
|
|
const reader = response.body.getReader();
|
|
let releasePromise: Promise<void> | undefined;
|
|
const release = () => {
|
|
releasePromise ??= lease.release();
|
|
return releasePromise;
|
|
};
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
try {
|
|
const chunk = await reader.read();
|
|
if (chunk.done) {
|
|
controller.close();
|
|
await release();
|
|
return;
|
|
}
|
|
controller.enqueue(chunk.value);
|
|
} catch (error) {
|
|
controller.error(error);
|
|
await release();
|
|
}
|
|
},
|
|
async cancel(reason) {
|
|
try {
|
|
await reader.cancel(reason);
|
|
} finally {
|
|
await release();
|
|
}
|
|
},
|
|
});
|
|
return new Response(body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
});
|
|
}
|
|
|
|
function gdprExportDownloadGateKeys(input: AcquireGdprExportDownloadGateInput): [string, string] {
|
|
if (input.scope !== "portal" && input.scope !== "privacy") throw new Error("GDPR export download gate scope is invalid.");
|
|
const tenantId = normalizeIdentifier(input.tenantId, "tenant");
|
|
const principalId = normalizeIdentifier(input.principalId, "principal");
|
|
const artifactId = normalizeIdentifier(input.artifactId, "artifact");
|
|
if (Buffer.byteLength(input.secret, "utf8") < 32) throw new Error("GDPR export download gate secret is invalid.");
|
|
|
|
const digest = (dimension: "principal" | "artifact", value: string) =>
|
|
createHmac("sha256", input.secret)
|
|
.update(`gdpr-export-download-v1\0${input.scope}\0${dimension}\0${tenantId}\0${value}`)
|
|
.digest("hex");
|
|
return [
|
|
`gdpr-export-download-v1:${input.scope}:principal:${digest("principal", principalId)}`,
|
|
`gdpr-export-download-v1:${input.scope}:artifact:${digest("artifact", artifactId)}`,
|
|
];
|
|
}
|
|
|
|
function normalizeIdentifier(value: string, name: "tenant" | "principal" | "artifact"): string {
|
|
const normalized = value.trim();
|
|
if (!/^[A-Za-z0-9_-]{8,191}$/u.test(normalized)) throw new Error(`GDPR export download ${name} identifier is invalid.`);
|
|
return normalized;
|
|
}
|
|
|
|
async function releaseLease(
|
|
store: SharedStore,
|
|
keys: [string, string],
|
|
owner: string,
|
|
scope: GdprExportDownloadGateScope,
|
|
): Promise<void> {
|
|
try {
|
|
await store.eval(RELEASE_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT, keys, [owner]);
|
|
} catch (error) {
|
|
logGateFailure("release", scope, error);
|
|
}
|
|
}
|
|
|
|
async function renewLease(
|
|
store: SharedStore,
|
|
keys: [string, string],
|
|
owner: string,
|
|
scope: GdprExportDownloadGateScope,
|
|
): Promise<void> {
|
|
try {
|
|
const renewed = await store.eval(RENEW_GDPR_EXPORT_DOWNLOAD_LEASE_SCRIPT, keys, [
|
|
owner,
|
|
String(GDPR_EXPORT_DOWNLOAD_LEASE_TTL_SECONDS),
|
|
]);
|
|
if (Number(renewed) !== 1) logGateFailure("renew", scope, new Error("Lease ownership was lost."));
|
|
} catch (error) {
|
|
logGateFailure("renew", scope, error);
|
|
}
|
|
}
|
|
|
|
function logGateFailure(operation: "acquire" | "renew" | "release", scope: GdprExportDownloadGateScope, error: unknown): void {
|
|
logger.error(
|
|
{
|
|
event: "gdpr_export.download_gate_unavailable",
|
|
operation,
|
|
downloadScope: scope,
|
|
errorName: error instanceof Error ? error.name : "UnknownError",
|
|
},
|
|
"GDPR export download gate unavailable",
|
|
);
|
|
}
|
|
|
|
export function __setGdprExportDownloadGateStoreForTests(store: SharedStore): void {
|
|
storeFactory = () => store;
|
|
}
|
|
|
|
export function __resetGdprExportDownloadGateStoreForTests(): void {
|
|
storeFactory = () => getSharedStore();
|
|
}
|