Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/privacy/gdprExportDownloadGate.server.ts
T

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();
}