All the UI fixes for now
This commit is contained in:
71
src/lib/crypto.ts
Normal file
71
src/lib/crypto.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* AES-256-GCM encryption for tenant package credentials.
|
||||
*
|
||||
* Credentials are encrypted before storage in tenant_requests.encrypted_secrets
|
||||
* and decrypted only during admin approval to write to OpenBao tenant paths.
|
||||
*
|
||||
* Format: [12-byte IV][ciphertext][16-byte auth tag] as a single Buffer.
|
||||
*
|
||||
* Provision the key:
|
||||
* bao kv put pieced/portal/encryption-key key="$(openssl rand -hex 32)"
|
||||
*/
|
||||
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
let cachedKey: Buffer | null = null;
|
||||
|
||||
async function getEncryptionKey(): Promise<Buffer> {
|
||||
if (cachedKey) return cachedKey;
|
||||
|
||||
const { readSecret } = await import("./openbao");
|
||||
const data = await readSecret("pieced/portal/encryption-key");
|
||||
const hex = data?.key;
|
||||
if (!hex || typeof hex !== "string" || hex.length !== 64) {
|
||||
throw new Error(
|
||||
"Invalid encryption key at secret/data/pieced/portal/encryption-key"
|
||||
);
|
||||
}
|
||||
cachedKey = Buffer.from(hex, "hex");
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
export async function encryptSecrets(
|
||||
secrets: Record<string, Record<string, string>>
|
||||
): Promise<Buffer> {
|
||||
const key = await getEncryptionKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const plaintext = JSON.stringify(secrets);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([iv, encrypted, tag]);
|
||||
}
|
||||
|
||||
export async function decryptSecrets(
|
||||
blob: Buffer
|
||||
): Promise<Record<string, Record<string, string>>> {
|
||||
const key = await getEncryptionKey();
|
||||
|
||||
const iv = blob.subarray(0, IV_LENGTH);
|
||||
const tag = blob.subarray(blob.length - TAG_LENGTH);
|
||||
const ciphertext = blob.subarray(IV_LENGTH, blob.length - TAG_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return JSON.parse(decrypted.toString("utf8"));
|
||||
}
|
||||
@@ -34,27 +34,31 @@ function getPool(): Pool {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS tenant_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
zitadel_org_id TEXT NOT NULL UNIQUE,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
contact_name TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL,
|
||||
agent_name TEXT NOT NULL DEFAULT 'Assistant',
|
||||
soul_md TEXT,
|
||||
packages TEXT[] DEFAULT '{}',
|
||||
billing_address JSONB DEFAULT '{}',
|
||||
billing_notes TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
admin_notes TEXT,
|
||||
tenant_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tenant_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
zitadel_org_id TEXT NOT NULL UNIQUE,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
contact_name TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL,
|
||||
agent_name TEXT NOT NULL DEFAULT 'Assistant',
|
||||
soul_md TEXT,
|
||||
packages TEXT[] DEFAULT '{}',
|
||||
billing_address JSONB DEFAULT '{}',
|
||||
billing_notes TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
admin_notes TEXT,
|
||||
tenant_name TEXT,
|
||||
encrypted_secrets BYTEA,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
|
||||
|
||||
-- Idempotent column add for existing databases
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA;
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -70,14 +74,17 @@ export async function ensureSchema(): Promise<void> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createTenantRequest(
|
||||
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt">
|
||||
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt"> & {
|
||||
encryptedSecrets?: Buffer;
|
||||
}
|
||||
): Promise<TenantRequest> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
`INSERT INTO tenant_requests
|
||||
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
|
||||
contact_email, agent_name, soul_md, packages, billing_address, billing_notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
|
||||
contact_email, agent_name, soul_md, packages, billing_address,
|
||||
billing_notes, encrypted_secrets)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
@@ -90,6 +97,7 @@ export async function createTenantRequest(
|
||||
params.packages,
|
||||
JSON.stringify(params.billingAddress),
|
||||
params.billingNotes,
|
||||
params.encryptedSecrets ?? null,
|
||||
]
|
||||
);
|
||||
return mapRow(result.rows[0]);
|
||||
@@ -154,6 +162,41 @@ export async function updateTenantRequestStatus(
|
||||
return mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the encrypted_secrets column after secrets have been written to OpenBao.
|
||||
* Called during admin approval after successful vault writes.
|
||||
*/
|
||||
export async function clearEncryptedSecrets(requestId: string): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"UPDATE tenant_requests SET encrypted_secrets = NULL, updated_at = now() WHERE id = $1",
|
||||
[requestId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a tenant request as "deleted" when the associated tenant CR is deleted.
|
||||
* This allows the customer to re-submit the onboarding wizard.
|
||||
*/
|
||||
export async function markTenantRequestDeletedByTenantName(
|
||||
tenantName: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"UPDATE tenant_requests SET status = 'deleted', tenant_name = NULL, updated_at = now() WHERE tenant_name = $1",
|
||||
[tenantName]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tenant request row entirely. Used when a customer re-submits
|
||||
* after their previous tenant was deleted by admin.
|
||||
*/
|
||||
export async function deleteTenantRequest(id: string): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query("DELETE FROM tenant_requests WHERE id = $1", [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync provisioning statuses: for all requests with status "provisioning",
|
||||
* check if the PiecedTenant CR has reached "Ready" and update to "active".
|
||||
@@ -205,6 +248,7 @@ function mapRow(row: any): TenantRequest {
|
||||
status: row.status as TenantRequestStatus,
|
||||
adminNotes: row.admin_notes,
|
||||
tenantName: row.tenant_name,
|
||||
encryptedSecrets: row.encrypted_secrets ?? null,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
|
||||
@@ -39,6 +39,28 @@ async function authenticate(): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a KV v2 secret. Path relative to KV mount.
|
||||
* Returns .data.data object or null if 404.
|
||||
*/
|
||||
export async function readSecret(
|
||||
path: string
|
||||
): Promise<Record<string, string> | null> {
|
||||
const token = await authenticate();
|
||||
const res = await fetch(`${OPENBAO_ADDR}/v1/secret/data/${path}`, {
|
||||
headers: { "X-Vault-Token": token },
|
||||
});
|
||||
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`OpenBao read failed: ${res.status} ${body}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data?.data ?? null;
|
||||
}
|
||||
|
||||
export async function writePackageSecrets(
|
||||
tenantId: string,
|
||||
packageId: string,
|
||||
|
||||
Reference in New Issue
Block a user