268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
import { constants as fsConstants } from "node:fs";
|
|
import { chmod, lstat, mkdir, open } from "node:fs/promises";
|
|
import { createHash, createPrivateKey, randomBytes, randomUUID, sign } from "node:crypto";
|
|
import { join, resolve } from "node:path";
|
|
|
|
const SIGNATURE_SCHEMA = "nodedc.external-data-plane.managed-provisioner-request/v1";
|
|
const TOKEN_PATTERN = /^ndc_edprb_[A-Za-z0-9_-]{32,512}$/;
|
|
const IDENTITY_PATTERN = /^[a-z][a-z0-9._:-]{2,127}$/i;
|
|
const AUDIENCE_PATTERN = /^[a-z][a-z0-9._:/-]{2,255}$/i;
|
|
|
|
function provisionerError(code, statusCode = 500) {
|
|
return Object.assign(new Error(code), { statusCode });
|
|
}
|
|
|
|
function targetAddress(target) {
|
|
const applicationId = String(target?.application?.id || target?.applicationId || "");
|
|
const pageId = String(target?.page?.id || target?.pageId || "");
|
|
const bindingId = String(target?.binding?.id || target?.bindingId || "");
|
|
if (!/^[0-9a-f-]{36}$/i.test(applicationId) || !IDENTITY_PATTERN.test(pageId)
|
|
|| !IDENTITY_PATTERN.test(bindingId)) {
|
|
throw provisionerError("foundry_reader_grant_target_invalid", 400);
|
|
}
|
|
return { applicationId, pageId, bindingId };
|
|
}
|
|
|
|
function targetIdentity(target) {
|
|
const address = targetAddress(target);
|
|
const dataProductId = String(target?.binding?.dataProductId || target?.dataProductId || "");
|
|
if (!IDENTITY_PATTERN.test(dataProductId)) throw provisionerError("foundry_reader_grant_target_invalid", 400);
|
|
const { applicationId, pageId, bindingId } = address;
|
|
return { applicationId, pageId, bindingId, dataProductId };
|
|
}
|
|
|
|
function targetDigest(target) {
|
|
const identity = targetAddress(target);
|
|
return createHash("sha256")
|
|
.update(`${identity.applicationId}/${identity.pageId}/${identity.bindingId}`, "utf8")
|
|
.digest("hex");
|
|
}
|
|
|
|
function signingPayload({ audience, serviceId, keyId, method, path, timestamp, nonce, bodySha256 }) {
|
|
return JSON.stringify({
|
|
schemaVersion: SIGNATURE_SCHEMA,
|
|
audience,
|
|
serviceId,
|
|
keyId,
|
|
method,
|
|
path,
|
|
timestamp,
|
|
nonce,
|
|
bodySha256,
|
|
});
|
|
}
|
|
|
|
export function createFoundryReaderGrantProvisioner({
|
|
dataPlaneUrl,
|
|
privateKeyFile,
|
|
grantsDir,
|
|
serviceId = "nodedc-module-foundry",
|
|
keyId = "foundry-edp-managed-provisioner-v1",
|
|
audience = "nodedc-external-data-plane.managed-provisioning.v1",
|
|
fetchImpl = fetch,
|
|
now = () => new Date(),
|
|
randomBytesImpl = randomBytes,
|
|
randomUUIDImpl = randomUUID,
|
|
production = process.env.NODE_ENV === "production",
|
|
}) {
|
|
const baseUrl = String(dataPlaneUrl || "").trim().replace(/\/$/, "");
|
|
const keyPath = String(privateKeyFile || "").trim();
|
|
const tokenRoot = String(grantsDir || "").trim();
|
|
const configured = Boolean(baseUrl && keyPath && tokenRoot && IDENTITY_PATTERN.test(serviceId)
|
|
&& IDENTITY_PATTERN.test(keyId) && AUDIENCE_PATTERN.test(audience));
|
|
let privateKeyPromise = null;
|
|
|
|
async function loadPrivateKey() {
|
|
if (!configured) throw provisionerError("foundry_reader_grant_provisioner_not_configured", 503);
|
|
if (!privateKeyPromise) privateKeyPromise = readPrivateKeySecurely(keyPath, { production });
|
|
return privateKeyPromise;
|
|
}
|
|
|
|
async function signedRequest(method, path, value) {
|
|
const privateKey = await loadPrivateKey();
|
|
const body = JSON.stringify(value);
|
|
const bodySha256 = createHash("sha256").update(body, "utf8").digest("hex");
|
|
const timestamp = now().toISOString();
|
|
const nonce = randomUUIDImpl();
|
|
const signature = sign(null, Buffer.from(signingPayload({
|
|
audience,
|
|
serviceId,
|
|
keyId,
|
|
method,
|
|
path,
|
|
timestamp,
|
|
nonce,
|
|
bodySha256,
|
|
}), "utf8"), privateKey).toString("base64url");
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(new URL(path, `${baseUrl}/`), {
|
|
method,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
accept: "application/json",
|
|
"x-nodedc-engine-service-id": serviceId,
|
|
"x-nodedc-engine-key-id": keyId,
|
|
"x-nodedc-request-audience": audience,
|
|
"x-nodedc-request-timestamp": timestamp,
|
|
"x-nodedc-request-nonce": nonce,
|
|
"x-nodedc-content-sha256": bodySha256,
|
|
"x-nodedc-request-signature": signature,
|
|
},
|
|
body,
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
} catch {
|
|
throw provisionerError("foundry_reader_grant_provisioner_unavailable", 503);
|
|
}
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
const code = String(payload?.error || payload?.code || "foundry_reader_grant_provisioner_rejected");
|
|
throw provisionerError(/^[a-z][a-z0-9_]{2,120}$/.test(code) ? code : "foundry_reader_grant_provisioner_rejected", response.status);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function plan(target) {
|
|
const identity = targetIdentity(target);
|
|
const payload = await signedRequest("POST", "/internal/data-plane/v1/consumer-reader-bindings/plan", {
|
|
allowedDataProductIds: [identity.dataProductId],
|
|
});
|
|
const product = Array.isArray(payload?.dataProducts)
|
|
? payload.dataProducts.find((candidate) => candidate?.id === identity.dataProductId)
|
|
: null;
|
|
if (!product || payload?.sourceScope !== "resolved-server-side") {
|
|
throw provisionerError("foundry_reader_grant_plan_response_invalid", 502);
|
|
}
|
|
return { product, sourceScope: "resolved-server-side" };
|
|
}
|
|
|
|
async function ensure(target) {
|
|
const identity = targetIdentity(target);
|
|
const digest = targetDigest(identity);
|
|
const token = await ensureReaderToken(join(resolve(tokenRoot), digest), {
|
|
production,
|
|
randomBytesImpl,
|
|
});
|
|
const bindingKey = `fndrc-${digest}`;
|
|
const payload = await signedRequest(
|
|
"PUT",
|
|
`/internal/data-plane/v1/consumer-reader-bindings/by-key/${encodeURIComponent(bindingKey)}`,
|
|
{
|
|
allowedDataProductIds: [identity.dataProductId],
|
|
expiresAt: null,
|
|
generation: 1,
|
|
capabilityDigest: createHash("sha256").update(token, "utf8").digest("hex"),
|
|
},
|
|
);
|
|
const binding = payload?.readerBinding;
|
|
if (binding?.bindingKey !== bindingKey || binding?.generation !== 1 || binding?.active !== true
|
|
|| binding?.expiresAt !== null || binding?.sourceScope !== "resolved-server-side"
|
|
|| !Array.isArray(binding?.allowedDataProductIds)
|
|
|| !binding.allowedDataProductIds.includes(identity.dataProductId)) {
|
|
throw provisionerError("foundry_reader_grant_ensure_response_invalid", 502);
|
|
}
|
|
return { ensured: true, idempotent: payload?.idempotent === true, generation: 1, sourceScope: "resolved-server-side" };
|
|
}
|
|
|
|
async function readToken(target) {
|
|
if (!tokenRoot) return null;
|
|
return readReaderToken(join(resolve(tokenRoot), targetDigest(target)), { production, missing: null });
|
|
}
|
|
|
|
return { configured, plan, ensure, readToken };
|
|
}
|
|
|
|
async function readPrivateKeySecurely(path, { production }) {
|
|
let handle;
|
|
try {
|
|
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
} catch {
|
|
throw provisionerError("foundry_reader_grant_private_key_unreadable", 503);
|
|
}
|
|
try {
|
|
const metadata = await handle.stat();
|
|
if (!metadata.isFile() || metadata.size < 80 || metadata.size > 8192 || (metadata.mode & 0o022) !== 0
|
|
|| (production && metadata.uid !== 0)) {
|
|
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
|
|
}
|
|
const pem = await handle.readFile("utf8");
|
|
const privateKey = createPrivateKey(pem);
|
|
if (privateKey.type !== "private" || privateKey.asymmetricKeyType !== "ed25519") {
|
|
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
|
|
}
|
|
return privateKey;
|
|
} catch (error) {
|
|
if (String(error?.message || "").startsWith("foundry_reader_grant_")) throw error;
|
|
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
async function ensureReaderToken(path, { production, randomBytesImpl }) {
|
|
const existing = await readReaderToken(path, { production, missing: null });
|
|
if (existing) return existing;
|
|
const directory = resolve(path, "..");
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
await chmod(directory, 0o700);
|
|
const directoryMetadata = await lstat(directory);
|
|
if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()
|
|
|| (directoryMetadata.mode & 0o077) !== 0 || (production && directoryMetadata.uid !== 0)) {
|
|
throw provisionerError("foundry_reader_grant_store_invalid", 503);
|
|
}
|
|
const token = `ndc_edprb_${randomBytesImpl(32).toString("base64url")}`;
|
|
let handle;
|
|
try {
|
|
handle = await open(
|
|
path,
|
|
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW || 0),
|
|
0o400,
|
|
);
|
|
await handle.writeFile(`${token}\n`, "utf8");
|
|
await handle.sync();
|
|
await handle.chmod(0o400);
|
|
return token;
|
|
} catch (error) {
|
|
if (error?.code === "EEXIST") return readConcurrentlyCreatedReaderToken(path, { production });
|
|
throw provisionerError("foundry_reader_grant_store_unavailable", 503);
|
|
} finally {
|
|
await handle?.close();
|
|
}
|
|
}
|
|
|
|
async function readConcurrentlyCreatedReaderToken(path, { production }) {
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
try {
|
|
const token = await readReaderToken(path, { production, missing: null });
|
|
if (token) return token;
|
|
} catch (error) {
|
|
if (error?.message !== "foundry_reader_grant_invalid") throw error;
|
|
}
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 10));
|
|
}
|
|
throw provisionerError("foundry_reader_grant_store_unavailable", 503);
|
|
}
|
|
|
|
async function readReaderToken(path, { production, missing }) {
|
|
let handle;
|
|
try {
|
|
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return missing;
|
|
throw provisionerError("foundry_reader_grant_unavailable", 503);
|
|
}
|
|
try {
|
|
const metadata = await handle.stat();
|
|
if (!metadata.isFile() || metadata.size < 2 || metadata.size > 1024 || (metadata.mode & 0o222) !== 0
|
|
|| (production && metadata.uid !== 0)) {
|
|
throw provisionerError("foundry_reader_grant_invalid", 503);
|
|
}
|
|
const token = (await handle.readFile("utf8")).trim();
|
|
if (!TOKEN_PATTERN.test(token)) throw provisionerError("foundry_reader_grant_invalid", 503);
|
|
return token;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|