feat(foundry): add managed data consumers and agent settings
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user