Compare commits

..

6 Commits

9 changed files with 212 additions and 62 deletions
+2 -1
View File
@@ -25,7 +25,8 @@
"utils:dev": "docker compose -f .docker/docker-compose.yml up --build", "utils:dev": "docker compose -f .docker/docker-compose.yml up --build",
"utils:gen_private_keys": "bun ./utils/genPrivateKeys", "utils:gen_private_keys": "bun ./utils/genPrivateKeys",
"utils:create_admin_role": "bun ./utils/createAdminRole", "utils:create_admin_role": "bun ./utils/createAdminRole",
"utils:assign_user_role": "bun ./utils/assignUserRole" "utils:assign_user_role": "bun ./utils/assignUserRole",
"db:check": "bunx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --shadow-database-url $DATABASE_URL --exit-code"
}, },
"dependencies": { "dependencies": {
"@azure/msal-node": "^5.0.2", "@azure/msal-node": "^5.0.2",
@@ -0,0 +1,63 @@
-- CreateTable
CREATE TABLE "UnifiSite" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"siteId" TEXT NOT NULL,
"companyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UnifiSite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CatalogItem" (
"id" TEXT NOT NULL,
"cwCatalogId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"customerDescription" TEXT,
"internalNotes" TEXT,
"manufacturer" TEXT,
"manufactureCwId" INTEGER,
"partNumber" TEXT,
"vendorName" TEXT,
"vendorSku" TEXT,
"vendorCwId" INTEGER,
"price" DOUBLE PRECISION NOT NULL,
"cost" DOUBLE PRECISION NOT NULL,
"inactive" BOOLEAN NOT NULL DEFAULT false,
"salesTaxable" BOOLEAN NOT NULL DEFAULT true,
"onHand" INTEGER NOT NULL DEFAULT 0,
"cwLastUpdated" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CatalogItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_LinkedItems" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_LinkedItems_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "UnifiSite_siteId_key" ON "UnifiSite"("siteId");
-- CreateIndex
CREATE UNIQUE INDEX "CatalogItem_cwCatalogId_key" ON "CatalogItem"("cwCatalogId");
-- CreateIndex
CREATE INDEX "_LinkedItems_B_index" ON "_LinkedItems"("B");
-- AddForeignKey
ALTER TABLE "UnifiSite" ADD CONSTRAINT "UnifiSite_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_LinkedItems" ADD CONSTRAINT "_LinkedItems_A_fkey" FOREIGN KEY ("A") REFERENCES "CatalogItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_LinkedItems" ADD CONSTRAINT "_LinkedItems_B_fkey" FOREIGN KEY ("B") REFERENCES "CatalogItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+2 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono/tiny"; import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute"; import { createRoute } from "../../modules/api-utils/createRoute";
import * as msal from "@azure/msal-node"; import * as msal from "@azure/msal-node";
import { io, msalClient } from "../../constants"; import { API_BASE_URL, io, msalClient } from "../../constants";
import { users } from "../../managers/users"; import { users } from "../../managers/users";
/* /v1/auth/redirect */ /* /v1/auth/redirect */
@@ -11,7 +11,7 @@ export default createRoute("get", ["/redirect"], async (c) => {
const tokenRequest: msal.AuthorizationCodeRequest = { const tokenRequest: msal.AuthorizationCodeRequest = {
code: c.req.query().code as string, code: c.req.query().code as string,
scopes: ["user.read"], scopes: ["user.read"],
redirectUri: "http://localhost:3000/v1/auth/redirect", redirectUri: `${API_BASE_URL}/v1/auth/redirect`,
}; };
const authResult = await msalClient.acquireTokenByCode(tokenRequest); const authResult = await msalClient.acquireTokenByCode(tokenRequest);
+3 -1
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono/tiny"; import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute"; import { createRoute } from "../../modules/api-utils/createRoute";
import { API_BASE_URL } from "../../constants";
import cuid from "cuid"; import cuid from "cuid";
/* /v1/auth/uri */ /* /v1/auth/uri */
@@ -7,7 +8,8 @@ export default createRoute("get", ["/uri"], (c) => {
c.status(200); c.status(200);
const callbackKey = cuid(); const callbackKey = cuid();
const msUri = `https://login.microsoftonline.com/${process.env.MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize?client_id=${process.env.MICROSOFT_CLIENT_ID}&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fv1%2Fauth%2Fredirect&scope=openid+User.Read&state=${callbackKey}&prompt=login`; const redirectUri = encodeURIComponent(`${API_BASE_URL}/v1/auth/redirect`);
const msUri = `https://login.microsoftonline.com/${process.env.MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize?client_id=${process.env.MICROSOFT_CLIENT_ID}&response_type=code&redirect_uri=${redirectUri}&scope=openid+User.Read&state=${callbackKey}&prompt=login`;
return c.json({ return c.json({
status: 200, status: 200,
+12 -36
View File
@@ -1,5 +1,4 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import crypto from "crypto";
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import { Prisma, PrismaClient } from "../generated/prisma/client"; import { Prisma, PrismaClient } from "../generated/prisma/client";
import * as msal from "@azure/msal-node"; import * as msal from "@azure/msal-node";
@@ -18,6 +17,8 @@ interface EnvKey {
// ENV CONSTANTS // ENV CONSTANTS
export const PORT = process.env.PORT; export const PORT = process.env.PORT;
export const API_BASE_URL =
process.env.API_BASE_URL || `http://localhost:${PORT || 3000}`;
export const prisma = new PrismaClient({ adapter }); export const prisma = new PrismaClient({ adapter });
@@ -29,46 +30,21 @@ const isProduction = process.env.NODE_ENV === "production";
const readKeyFile = (path: string) => readFileSync(path).toString(); const readKeyFile = (path: string) => readFileSync(path).toString();
/** export const accessTokenPrivateKey = isProduction
* Convert a PKCS#1 PEM key to PKCS#8 PEM format.
* The compiled Bun binary on Ubuntu uses an OpenSSL that doesn't auto-detect PKCS#1 format,
* so we normalize all keys to PKCS#8 at load time.
*/
const toPkcs8Private = (pem: string) =>
crypto
.createPrivateKey({ key: pem, format: "pem", type: "pkcs1" })
.export({ type: "pkcs8", format: "pem" }) as string;
const toPkcs8Public = (pem: string) =>
crypto
.createPublicKey({ key: pem, format: "pem", type: "pkcs1" })
.export({ type: "spki", format: "pem" }) as string;
export const accessTokenPrivateKey = toPkcs8Private(
isProduction
? process.env.ACCESS_TOKEN_PRIVATE_KEY! ? process.env.ACCESS_TOKEN_PRIVATE_KEY!
: readKeyFile(`.accessToken.key`), : readKeyFile(`.accessToken.key`);
); export const refreshTokenPrivateKey = isProduction
export const refreshTokenPrivateKey = toPkcs8Private(
isProduction
? process.env.REFRESH_TOKEN_PRIVATE_KEY! ? process.env.REFRESH_TOKEN_PRIVATE_KEY!
: readKeyFile(`.refreshToken.key`), : readKeyFile(`.refreshToken.key`);
); export const permissionsPrivateKey = isProduction
export const permissionsPrivateKey = toPkcs8Private(
isProduction
? process.env.PERMISSIONS_PRIVATE_KEY! ? process.env.PERMISSIONS_PRIVATE_KEY!
: readKeyFile(`.permissions.key`), : readKeyFile(`.permissions.key`);
); export const secureValuesPrivateKey = isProduction
export const secureValuesPrivateKey = toPkcs8Private(
isProduction
? process.env.SECURE_VALUES_PRIVATE_KEY! ? process.env.SECURE_VALUES_PRIVATE_KEY!
: readKeyFile(`.secureValues.key`), : readKeyFile(`.secureValues.key`);
); export const secureValuesPublicKey = isProduction
export const secureValuesPublicKey = toPkcs8Public(
isProduction
? process.env.SECURE_VALUES_PUBLIC_KEY! ? process.env.SECURE_VALUES_PUBLIC_KEY!
: readKeyFile(`public-keys/.secureValues.pub`), : readKeyFile(`public-keys/.secureValues.pub`);
);
// Microsoft Auth Constants // Microsoft Auth Constants
const msalConfig: msal.Configuration = { const msalConfig: msal.Configuration = {
+35 -1
View File
@@ -1,15 +1,49 @@
import { refresh } from "./api/auth"; import { refresh } from "./api/auth";
import app from "./api/server"; import app from "./api/server";
import { engine, PORT, unifi, unifiPassword, unifiUsername } from "./constants"; import {
engine,
PORT,
prisma,
unifi,
unifiPassword,
unifiUsername,
} from "./constants";
import { unifiSites } from "./managers/unifiSites"; import { unifiSites } from "./managers/unifiSites";
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies"; import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog"; import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory"; import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
import { events, setupEventDebugger } from "./modules/globalEvents"; import { events, setupEventDebugger } from "./modules/globalEvents";
import { signPermissions } from "./modules/permission-utils/signPermissions";
import { RoleController } from "./controllers/RoleController";
import cuid from "cuid";
// Setup global event debugger in non-production environments // Setup global event debugger in non-production environments
if (Bun.env.NODE_ENV == "development") setupEventDebugger(); if (Bun.env.NODE_ENV == "development") setupEventDebugger();
// Ensure administrator role exists
const existingAdmin = await prisma.role.findFirst({
where: { moniker: "administrator" },
include: { users: { include: { roles: true } } },
});
if (!existingAdmin) {
const id = cuid();
const created = await prisma.role.create({
data: {
id,
moniker: "administrator",
title: "Admin",
permissions: signPermissions({
issuer: "roles",
subject: id,
permissions: ["*"],
}),
},
include: { users: { include: { roles: true } } },
});
events.emit("role:created", new RoleController(created));
}
// Refresh the internal list of companies every minute // Refresh the internal list of companies every minute
await refreshCompanies(); await refreshCompanies();
setInterval(() => { setInterval(() => {
+65
View File
@@ -0,0 +1,65 @@
import { execSync } from "child_process";
const kubeconfig = "/Users/jroberts/projects/K8S-QuickDeploy/k8s.yaml";
function getKey(name: string): string {
const b64 = execSync(
`KUBECONFIG=${kubeconfig} kubectl get secret optima-keys-secret -n optima -o jsonpath="{.data.${name}}"`,
)
.toString()
.trim();
return Buffer.from(b64, "base64").toString("utf-8");
}
const privKeys = [
"ACCESS_TOKEN_PRIVATE_KEY",
"REFRESH_TOKEN_PRIVATE_KEY",
"PERMISSIONS_PRIVATE_KEY",
"SECURE_VALUES_PRIVATE_KEY",
];
const converted: Record<string, string> = {};
// Use openssl CLI to convert PKCS#1 to PKCS#8 (Bun's crypto has issues with some keys)
for (const k of privKeys) {
const pem = getKey(k);
const pkcs8 = execSync("openssl pkey -in /dev/stdin", {
input: pem,
}).toString();
converted[k] = pkcs8;
console.log(`${k}: converted to PKCS#8 ✅`);
}
const pubPem = getKey("SECURE_VALUES_PUBLIC_KEY");
const spki = execSync("openssl rsa -RSAPublicKey_in -pubout -in /dev/stdin", {
input: pubPem,
}).toString();
converted["SECURE_VALUES_PUBLIC_KEY"] = spki;
console.log("SECURE_VALUES_PUBLIC_KEY: converted to SPKI ✅");
// Generate kubectl command to recreate the secret with PKCS#8 keys
const args = Object.entries(converted)
.map(([k, v]) => `--from-literal=${k}='${v}'`)
.join(" \\\n ");
console.log("\n--- Delete and recreate secret with PKCS#8 keys ---\n");
console.log(
`KUBECONFIG=${kubeconfig} kubectl delete secret optima-keys-secret -n optima`,
);
console.log(
`KUBECONFIG=${kubeconfig} kubectl create secret generic optima-keys-secret -n optima \\\n ${args}`,
);
// Actually do it
console.log("\nApplying...");
execSync(
`KUBECONFIG=${kubeconfig} kubectl delete secret optima-keys-secret -n optima`,
);
const literals = Object.entries(converted).map(
([k, v]) => `--from-literal=${k}=${v}`,
);
const cmd = `KUBECONFIG=${kubeconfig} kubectl create secret generic optima-keys-secret -n optima ${literals.join(" ")}`;
execSync(cmd);
console.log("Secret recreated with PKCS#8 keys ✅");
+9 -5
View File
@@ -1,9 +1,9 @@
import keypair from "keypair"; import crypto from "crypto";
console.log(` console.log(`
Generating Private Keys Generating Private Keys
----------------- -----------------
This script will go through and genrate all the keys necessary for running the Credential Manager API locally. This script will go through and generate all the keys necessary for running the Credential Manager API locally.
This process might take several minutes. This process might take several minutes.
-----------------`); -----------------`);
@@ -42,9 +42,13 @@ await Promise.all(
if (!privExists || !pubExists) { if (!privExists || !pubExists) {
// Always regenerate both files together to ensure the key pair matches // Always regenerate both files together to ensure the key pair matches
console.log(`Generating '${v}' and '${pubPath}'...`); console.log(`Generating '${v}' and '${pubPath}'...`);
const keys = keypair({ bits: 4096 }); const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", {
await Bun.write(v, keys.private); modulusLength: 4096,
await Bun.write(pubPath, keys.public); privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
await Bun.write(v, privateKey);
await Bun.write(pubPath, publicKey);
} }
return; return;
}), }),
+16 -11
View File
@@ -1,4 +1,4 @@
import keypair from "keypair"; import crypto from "crypto";
import { mkdirSync } from "fs"; import { mkdirSync } from "fs";
const outputDir = "production-keys"; const outputDir = "production-keys";
@@ -19,14 +19,18 @@ const generatedKeys: Record<string, { private: string; public: string }> = {};
for (const name of keyFiles) { for (const name of keyFiles) {
console.log(`Generating '${name}' key pair (4096-bit RSA)...`); console.log(`Generating '${name}' key pair (4096-bit RSA)...`);
const keys = keypair({ bits: 4096 }); const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", {
generatedKeys[name] = keys; modulusLength: 4096,
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
generatedKeys[name] = { private: privateKey, public: publicKey };
const privPath = `${outputDir}/${name}.key`; const privPath = `${outputDir}/${name}.key`;
const pubPath = `${outputDir}/${name}.pub`; const pubPath = `${outputDir}/${name}.pub`;
await Bun.write(privPath, keys.private); await Bun.write(privPath, privateKey);
await Bun.write(pubPath, keys.public); await Bun.write(pubPath, publicKey);
console.log(`${privPath}`); console.log(`${privPath}`);
console.log(`${pubPath}`); console.log(`${pubPath}`);
@@ -38,14 +42,15 @@ const toBase64 = (str: string) => Buffer.from(str).toString("base64");
const secretYaml = `apiVersion: v1 const secretYaml = `apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
name: optima-keys name: optima-keys-secret
namespace: optima
type: Opaque type: Opaque
data: data:
accessToken.key: ${toBase64(generatedKeys["accessToken"].private)} ACCESS_TOKEN_PRIVATE_KEY: ${toBase64(generatedKeys["accessToken"].private)}
refreshToken.key: ${toBase64(generatedKeys["refreshToken"].private)} REFRESH_TOKEN_PRIVATE_KEY: ${toBase64(generatedKeys["refreshToken"].private)}
permissions.key: ${toBase64(generatedKeys["permissions"].private)} PERMISSIONS_PRIVATE_KEY: ${toBase64(generatedKeys["permissions"].private)}
secureValues.key: ${toBase64(generatedKeys["secureValues"].private)} SECURE_VALUES_PRIVATE_KEY: ${toBase64(generatedKeys["secureValues"].private)}
secureValues.pub: ${toBase64(generatedKeys["secureValues"].public)} SECURE_VALUES_PUBLIC_KEY: ${toBase64(generatedKeys["secureValues"].public)}
`; `;
const secretPath = `${outputDir}/optima-keys-secret.yaml`; const secretPath = `${outputDir}/optima-keys-secret.yaml`;