diff --git a/package-lock.json b/package-lock.json index 5bb58341..4801202c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "cookie-parser": "1.4.7", "cookies": "^0.9.1", "cors": "2.8.5", + "crypto-js": "^4.2.0", "drizzle-orm": "0.38.3", "eslint": "9.17.0", "eslint-config-next": "15.1.3", @@ -94,6 +95,7 @@ "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.8", "@types/cors": "2.8.17", + "@types/crypto-js": "^4.2.2", "@types/express": "5.0.0", "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", @@ -4478,7 +4480,7 @@ "version": "7.6.12", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz", "integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4525,6 +4527,13 @@ "@types/node": "*" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -4619,7 +4628,7 @@ "version": "22.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4653,7 +4662,7 @@ "version": "19.1.1", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz", "integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -4663,7 +4672,7 @@ "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -6271,11 +6280,17 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -9584,7 +9599,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -15962,7 +15977,6 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz", "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { @@ -16330,7 +16344,6 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -16362,7 +16375,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/package.json b/package.json index cb18a7cc..f2ce2cd4 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "cookie-parser": "1.4.7", "cookies": "^0.9.1", "cors": "2.8.5", + "crypto-js": "^4.2.0", "drizzle-orm": "0.38.3", "eslint": "9.17.0", "eslint-config-next": "15.1.3", @@ -105,6 +106,7 @@ "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.8", "@types/cors": "2.8.17", + "@types/crypto-js": "^4.2.2", "@types/express": "5.0.0", "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 251abb45..e83031a1 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -77,7 +77,15 @@ export enum ActionsEnum { createIdpOrg = "createIdpOrg", deleteIdpOrg = "deleteIdpOrg", listIdpOrgs = "listIdpOrgs", - updateIdpOrg = "updateIdpOrg" + updateIdpOrg = "updateIdpOrg", + checkOrgId = "checkOrgId", + createApiKey = "createApiKey", + deleteApiKey = "deleteApiKey", + setApiKeyActions = "setApiKeyActions", + setApiKeyOrgs = "setApiKeyOrgs", + listApiKeyActions = "listApiKeyActions", + listApiKeys = "listApiKeys", + getApiKey = "getApiKey" } export async function checkUserActionPermission( diff --git a/server/db/schemas/index.ts b/server/db/schemas/index.ts index adf6d066..686fbd9e 100644 --- a/server/db/schemas/index.ts +++ b/server/db/schemas/index.ts @@ -1,2 +1 @@ export * from "./schema"; -export * from "./proSchema"; diff --git a/server/db/schemas/proSchema.ts b/server/db/schemas/proSchema.ts deleted file mode 100644 index 6b1d879c..00000000 --- a/server/db/schemas/proSchema.ts +++ /dev/null @@ -1,17 +0,0 @@ -// This file is licensed under the Fossorial Commercial License. -// Unauthorized use, copying, modification, or distribution is strictly prohibited. -// -// Copyright (c) 2025 Fossorial LLC. All rights reserved. - -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -export const licenseKey = sqliteTable("licenseKey", { - licenseKeyId: text("licenseKeyId").primaryKey().notNull(), - instanceId: text("instanceId").notNull(), - token: text("token").notNull() -}); - -export const hostMeta = sqliteTable("hostMeta", { - hostMetaId: text("hostMetaId").primaryKey().notNull(), - createdAt: integer("createdAt").notNull() -}); diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index b92759b6..4fb2416b 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -458,6 +458,57 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", { scopes: text("scopes").notNull() }); +export const licenseKey = sqliteTable("licenseKey", { + licenseKeyId: text("licenseKeyId").primaryKey().notNull(), + instanceId: text("instanceId").notNull(), + token: text("token").notNull() +}); + +export const hostMeta = sqliteTable("hostMeta", { + hostMetaId: text("hostMetaId").primaryKey().notNull(), + createdAt: integer("createdAt").notNull() +}); + +export const apiKeys = sqliteTable("apiKeys", { + apiKeyId: text("apiKeyId").primaryKey(), + name: text("name").notNull(), + apiKeyHash: text("apiKeyHash").notNull(), + lastChars: text("lastChars").notNull(), + createdAt: text("dateCreated").notNull(), + isRoot: integer("isRoot", { mode: "boolean" }).notNull().default(false) +}); + +export const apiKeyActions = sqliteTable("apiKeyActions", { + apiKeyId: text("apiKeyId") + .notNull() + .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), + actionId: text("actionId") + .notNull() + .references(() => actions.actionId, { onDelete: "cascade" }) +}); + +export const apiKeyOrg = sqliteTable("apiKeyOrg", { + apiKeyId: text("apiKeyId") + .notNull() + .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const idpOrg = sqliteTable("idpOrg", { + idpId: integer("idpId") + .notNull() + .references(() => idp.idpId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleMapping: text("roleMapping"), + orgMapping: text("orgMapping") +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -494,3 +545,6 @@ export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; export type SupporterKey = InferSelectModel; export type Idp = InferSelectModel; +export type ApiKey = InferSelectModel; +export type ApiKeyAction = InferSelectModel; +export type ApiKeyOrg = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index 0168535c..4c16caaa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,7 +4,9 @@ import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; -import { Session, User, UserOrg } from "./db/schemas/schema"; +import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "./db/schemas"; +import { createIntegrationApiServer } from "./integrationApiServer"; +import license from "./license/license.js"; async function startServers() { await runSetupFunctions(); @@ -14,10 +16,16 @@ async function startServers() { const internalServer = createInternalServer(); const nextServer = await createNextServer(); + let integrationServer; + if (await license.isUnlocked()) { + integrationServer = createIntegrationApiServer(); + } + return { apiServer, nextServer, internalServer, + integrationServer }; } @@ -25,9 +33,11 @@ async function startServers() { declare global { namespace Express { interface Request { + apiKey?: ApiKey; user?: User; session?: Session; userOrg?: UserOrg; + apiKeyOrg?: ApiKeyOrg; userOrgRoleId?: number; userOrgId?: string; userOrgIds?: string[]; diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts new file mode 100644 index 00000000..20925326 --- /dev/null +++ b/server/integrationApiServer.ts @@ -0,0 +1,112 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import express from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { + errorHandlerMiddleware, + notFoundMiddleware, + verifyValidLicense +} from "@server/middlewares"; +import { authenticated, unauthenticated } from "@server/routers/integration"; +import { logIncomingMiddleware } from "./middlewares/logIncoming"; +import { csrfProtectionMiddleware } from "./middlewares/csrfProtection"; +import helmet from "helmet"; +import swaggerUi from "swagger-ui-express"; +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import { registry } from "./openApi"; + +const dev = process.env.ENVIRONMENT !== "prod"; +const externalPort = config.getRawConfig().server.integration_port; + +export function createIntegrationApiServer() { + const apiServer = express(); + + apiServer.use(verifyValidLicense); + + if (config.getRawConfig().server.trust_proxy) { + apiServer.set("trust proxy", 1); + } + + apiServer.use(cors()); + + if (!dev) { + apiServer.use(helmet()); + apiServer.use(csrfProtectionMiddleware); + } + + apiServer.use(cookieParser()); + apiServer.use(express.json()); + + apiServer.use( + "/v1/docs", + swaggerUi.serve, + swaggerUi.setup(getOpenApiDocumentation()) + ); + + // API routes + const prefix = `/v1`; + apiServer.use(logIncomingMiddleware); + apiServer.use(prefix, unauthenticated); + apiServer.use(prefix, authenticated); + + // Error handling + apiServer.use(notFoundMiddleware); + apiServer.use(errorHandlerMiddleware); + + // Create HTTP server + const httpServer = apiServer.listen(externalPort, (err?: any) => { + if (err) throw err; + logger.info( + `Integration API server is running on http://localhost:${externalPort}` + ); + }); + + return httpServer; +} + +function getOpenApiDocumentation() { + const bearerAuth = registry.registerComponent( + "securitySchemes", + "Bearer Auth", + { + type: "http", + scheme: "bearer" + } + ); + + for (const def of registry.definitions) { + if (def.type === "route") { + def.route.security = [ + { + [bearerAuth.name]: [] + } + ]; + } + } + + registry.registerPath({ + method: "get", + path: "/", + description: "Health check", + tags: [], + request: {}, + responses: {} + }); + + const generator = new OpenApiGeneratorV3(registry.definitions); + + return generator.generateDocument({ + openapi: "3.0.0", + info: { + version: "v1", + title: "Pangolin Integration API" + }, + servers: [{ url: "/v1" }] + }); +} diff --git a/server/lib/config.ts b/server/lib/config.ts index e9675284..f2ae7afc 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -60,6 +60,10 @@ const configSchema = z.object({ } ), server: z.object({ + integration_port: portSchema + .optional() + .transform(stoi) + .pipe(portSchema.optional()), external_port: portSchema.optional().transform(stoi).pipe(portSchema), internal_port: portSchema.optional().transform(stoi).pipe(portSchema), next_port: portSchema.optional().transform(stoi).pipe(portSchema), @@ -96,14 +100,7 @@ const configSchema = z.object({ .string() .optional() .transform(getEnvOrYaml("SERVER_SECRET")) - .pipe( - z - .string() - .min( - 32, - "SERVER_SECRET must be at least 32 characters long" - ) - ) + .pipe(z.string().min(8)) }), traefik: z.object({ http_entrypoint: z.string(), @@ -267,6 +264,8 @@ export class Config { : "false"; process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; + license.setServerSecret(parsedConfig.data.server.secret); + this.checkKeyStatus(); this.rawConfig = parsedConfig.data; @@ -274,7 +273,6 @@ export class Config { private async checkKeyStatus() { const licenseStatus = await license.check(); - console.log("License status", licenseStatus); if (!licenseStatus.isHostLicensed) { this.checkSupporterKey(); } diff --git a/server/lib/crypto.ts b/server/lib/crypto.ts index e1e9c2b1..bd7df85a 100644 --- a/server/lib/crypto.ts +++ b/server/lib/crypto.ts @@ -1,40 +1,12 @@ -import * as crypto from "crypto"; - -const ALGORITHM = "aes-256-gcm"; +import CryptoJS from "crypto-js"; export function encrypt(value: string, key: string): string { - const iv = crypto.randomBytes(12); - const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input - - const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); - - const encrypted = Buffer.concat([ - cipher.update(value, "utf8"), - cipher.final() - ]); - const authTag = cipher.getAuthTag(); - - return [ - iv.toString("base64"), - encrypted.toString("base64"), - authTag.toString("base64") - ].join(":"); + const ciphertext = CryptoJS.AES.encrypt(value, key).toString(); + return ciphertext; } export function decrypt(encryptedValue: string, key: string): string { - const [ivB64, encryptedB64, authTagB64] = encryptedValue.split(":"); - - const iv = Buffer.from(ivB64, "base64"); - const encrypted = Buffer.from(encryptedB64, "base64"); - const authTag = Buffer.from(authTagB64, "base64"); - const keyBuffer = Buffer.from(key, "base64"); - - const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); - decipher.setAuthTag(authTag); - - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final() - ]); - return decrypted.toString("utf8"); + const bytes = CryptoJS.AES.decrypt(encryptedValue, key); + const originalText = bytes.toString(CryptoJS.enc.Utf8); + return originalText; } diff --git a/server/license/license.ts b/server/license/license.ts index 68704e26..b1398d13 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -10,6 +10,8 @@ import NodeCache from "node-cache"; import { validateJWT } from "./licenseJwt"; import { count, eq } from "drizzle-orm"; import moment from "moment"; +import { setHostMeta } from "@server/setup/setHostMeta"; +import { encrypt, decrypt } from "@server/lib/crypto"; export type LicenseStatus = { isHostLicensed: boolean; // Are there any license keys? @@ -21,6 +23,7 @@ export type LicenseStatus = { export type LicenseKeyCache = { licenseKey: string; + licenseKeyEncrypted: string; valid: boolean; iat?: Date; type?: "LICENSE" | "SITES"; @@ -69,6 +72,7 @@ export class License { private ephemeralKey!: string; private statusKey = "status"; + private serverSecret!: string; private publicKey = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF @@ -100,6 +104,10 @@ LQIDAQAB }); } + public setServerSecret(secret: string) { + this.serverSecret = secret; + } + public async forceRecheck() { this.statusCache.flushAll(); this.licenseKeyCache.flushAll(); @@ -129,8 +137,7 @@ LQIDAQAB hostId: this.hostId, isHostLicensed: true, isLicenseValid: false, - maxSites: undefined, - usedSites: 150 + maxSites: undefined }; try { @@ -143,8 +150,6 @@ LQIDAQAB // Invalidate all this.licenseKeyCache.flushAll(); - logger.debug("Checking license status..."); - const allKeysRes = await db.select().from(licenseKey); if (allKeysRes.length === 0) { @@ -152,24 +157,37 @@ LQIDAQAB return status; } + let foundHostKey = false; // Validate stored license keys for (const key of allKeysRes) { try { - const payload = validateJWT( + // Decrypt the license key and token + const decryptedKey = decrypt( + key.licenseKeyId, + this.serverSecret + ); + const decryptedToken = decrypt( key.token, + this.serverSecret + ); + + const payload = validateJWT( + decryptedToken, this.publicKey ); - this.licenseKeyCache.set( - key.licenseKeyId, - { - licenseKey: key.licenseKeyId, - valid: payload.valid, - type: payload.type, - numSites: payload.quantity, - iat: new Date(payload.iat * 1000) - } - ); + this.licenseKeyCache.set(decryptedKey, { + licenseKey: decryptedKey, + licenseKeyEncrypted: key.licenseKeyId, + valid: payload.valid, + type: payload.type, + numSites: payload.quantity, + iat: new Date(payload.iat * 1000) + }); + + if (payload.type === "LICENSE") { + foundHostKey = true; + } } catch (e) { logger.error( `Error validating license key: ${key.licenseKeyId}` @@ -180,15 +198,21 @@ LQIDAQAB key.licenseKeyId, { licenseKey: key.licenseKeyId, + licenseKeyEncrypted: key.licenseKeyId, valid: false } ); } } + if (!foundHostKey && allKeysRes.length) { + logger.debug("No host license key found"); + status.isHostLicensed = false; + } + const keys = allKeysRes.map((key) => ({ - licenseKey: key.licenseKeyId, - instanceId: key.instanceId + licenseKey: decrypt(key.licenseKeyId, this.serverSecret), + instanceId: decrypt(key.instanceId, this.serverSecret) })); let apiResponse: ValidateLicenseAPIResponse | undefined; @@ -251,12 +275,22 @@ LQIDAQAB cached.numSites = payload.quantity; cached.iat = new Date(payload.iat * 1000); + // Encrypt the updated token before storing + const encryptedKey = encrypt( + key.licenseKey, + this.serverSecret + ); + const encryptedToken = encrypt( + licenseKeyRes, + this.serverSecret + ); + await db .update(licenseKey) .set({ - token: licenseKeyRes + token: encryptedToken }) - .where(eq(licenseKey.licenseKeyId, key.licenseKey)); + .where(eq(licenseKey.licenseKeyId, encryptedKey)); this.licenseKeyCache.set( key.licenseKey, @@ -300,10 +334,13 @@ LQIDAQAB } public async activateLicenseKey(key: string) { + // Encrypt the license key before storing + const encryptedKey = encrypt(key, this.serverSecret); + const [existingKey] = await db .select() .from(licenseKey) - .where(eq(licenseKey.licenseKeyId, key)) + .where(eq(licenseKey.licenseKeyId, encryptedKey)) .limit(1); if (existingKey) { @@ -380,11 +417,15 @@ LQIDAQAB throw new Error("Invalid license key"); } + const encryptedToken = encrypt(licenseKeyRes, this.serverSecret); + // Encrypt the instanceId before storing + const encryptedInstanceId = encrypt(instanceId!, this.serverSecret); + // Store the license key in the database await db.insert(licenseKey).values({ - licenseKeyId: key, - token: licenseKeyRes, - instanceId: instanceId! + licenseKeyId: encryptedKey, + token: encryptedToken, + instanceId: encryptedInstanceId }); } catch (error) { throw Error(`Error validating license key: ${error}`); @@ -400,13 +441,21 @@ LQIDAQAB instanceId: string; }[] ): Promise { + // Decrypt the instanceIds before sending to the server + const decryptedKeys = keys.map((key) => ({ + licenseKey: key.licenseKey, + instanceId: key.instanceId + ? decrypt(key.instanceId, this.serverSecret) + : key.instanceId + })); + const response = await fetch(this.validationServerUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - licenseKeys: keys, + licenseKeys: decryptedKeys, ephemeralKey: this.ephemeralKey, instanceName: this.hostId }) @@ -418,6 +467,8 @@ LQIDAQAB } } +await setHostMeta(); + const [info] = await db.select().from(hostMeta).limit(1); if (!info) { diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index f5a9cdc1..03d6f3bb 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -16,3 +16,7 @@ export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; export * from "./verifyUserIsServerAdmin"; export * from "./verifyIsLoggedInUser"; +export * from "./integration"; +export * from "./verifyValidLicense"; +export * from "./verifyUserHasAction"; +export * from "./verifyApiKeyAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts new file mode 100644 index 00000000..c16e1294 --- /dev/null +++ b/server/middlewares/integration/index.ts @@ -0,0 +1,17 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +export * from "./verifyApiKey"; +export * from "./verifyApiKeyOrgAccess"; +export * from "./verifyApiKeyHasAction"; +export * from "./verifyApiKeySiteAccess"; +export * from "./verifyApiKeyResourceAccess"; +export * from "./verifyApiKeyTargetAccess"; +export * from "./verifyApiKeyRoleAccess"; +export * from "./verifyApiKeyUserAccess"; +export * from "./verifyApiKeySetResourceUsers"; +export * from "./verifyAccessTokenAccess"; +export * from "./verifyApiKeyIsRoot"; +export * from "./verifyApiKeyApiKeyAccess"; diff --git a/server/middlewares/integration/verifyAccessTokenAccess.ts b/server/middlewares/integration/verifyAccessTokenAccess.ts new file mode 100644 index 00000000..82badcd4 --- /dev/null +++ b/server/middlewares/integration/verifyAccessTokenAccess.ts @@ -0,0 +1,115 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resourceAccessToken, resources, apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyAccessTokenAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const accessTokenId = req.params.accessTokenId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + const [accessToken] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!accessToken) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Access token with ID ${accessTokenId} not found` + ) + ); + } + + const resourceId = accessToken.resourceId; + + if (!resourceId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Access token with ID ${accessTokenId} does not have a resource ID` + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying access token access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKey.ts b/server/middlewares/integration/verifyApiKey.ts new file mode 100644 index 00000000..39fc3de6 --- /dev/null +++ b/server/middlewares/integration/verifyApiKey.ts @@ -0,0 +1,65 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { verifyPassword } from "@server/auth/password"; +import db from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; + +export async function verifyApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const authHeader = req.headers["authorization"]; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "API key required") + ); + } + + const key = authHeader.split(" ")[1]; // Get the token part after "Bearer" + const [apiKeyId, apiKeySecret] = key.split("."); + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key") + ); + } + + const secretHash = apiKey.apiKeyHash; + const valid = await verifyPassword(apiKeySecret, secretHash); + + if (!valid) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key") + ); + } + + req.apiKey = apiKey; + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred checking API key" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts b/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts new file mode 100644 index 00000000..aedc60c1 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts @@ -0,0 +1,86 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { apiKeys, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const {apiKey: callerApiKey } = req; + + const apiKeyId = + req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; + const orgId = req.params.orgId; + + if (!callerApiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!apiKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [callerApiKeyOrg] = await db + .select() + .from(apiKeyOrg) + .where( + and(eq(apiKeys.apiKeyId, callerApiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!callerApiKeyOrg) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `API key with ID ${apiKeyId} does not have an organization ID` + ) + ); + } + + const [otherApiKeyOrg] = await db + .select() + .from(apiKeyOrg) + .where( + and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!otherApiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `API key with ID ${apiKeyId} does not have access to organization with ID ${orgId}` + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyHasAction.ts b/server/middlewares/integration/verifyApiKeyHasAction.ts new file mode 100644 index 00000000..0326c465 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyHasAction.ts @@ -0,0 +1,61 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum } from "@server/auth/actions"; +import db from "@server/db"; +import { apiKeyActions } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; + +export function verifyApiKeyHasAction(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + if (!req.apiKey) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "API Key not authenticated" + ) + ); + } + + const [actionRes] = await db + .select() + .from(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, req.apiKey.apiKeyId), + eq(apiKeyActions.actionId, action) + ) + ); + + if (!actionRes) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have permission perform this action" + ) + ); + } + + return next(); + } catch (error) { + logger.error("Error verifying key action access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key action access" + ) + ); + } + }; +} diff --git a/server/middlewares/integration/verifyApiKeyIsRoot.ts b/server/middlewares/integration/verifyApiKeyIsRoot.ts new file mode 100644 index 00000000..35cd0faf --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyIsRoot.ts @@ -0,0 +1,44 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; + +export async function verifyApiKeyIsRoot( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { apiKey } = req; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!apiKey.isRoot) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have root access" + ) + ); + } + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred checking API key" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyOrgAccess.ts b/server/middlewares/integration/verifyApiKeyOrgAccess.ts new file mode 100644 index 00000000..e1e1e0d4 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyOrgAccess.ts @@ -0,0 +1,66 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; + +export async function verifyApiKeyOrgAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKeyId = req.apiKey?.apiKeyId; + const orgId = req.params.orgId; + + if (!apiKeyId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying organization access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyResourceAccess.ts b/server/middlewares/integration/verifyApiKeyResourceAccess.ts new file mode 100644 index 00000000..49180b59 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyResourceAccess.ts @@ -0,0 +1,90 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resources, apiKeyOrg } from "@server/db/schemas"; +import { eq, and } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyResourceAccess( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const resourceId = + req.params.resourceId || req.body.resourceId || req.query.resourceId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + try { + // Retrieve the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyRoleAccess.ts b/server/middlewares/integration/verifyApiKeyRoleAccess.ts new file mode 100644 index 00000000..a7abf9a6 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyRoleAccess.ts @@ -0,0 +1,132 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { roles, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; + +export async function verifyApiKeyRoleAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const singleRoleId = parseInt( + req.params.roleId || req.body.roleId || req.query.roleId + ); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + const { roleIds } = req.body; + const allRoleIds = + roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); + + if (allRoleIds.length === 0) { + return next(); + } + + const rolesData = await db + .select() + .from(roles) + .where(inArray(roles.roleId, allRoleIds)); + + if (rolesData.length !== allRoleIds.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "One or more roles not found" + ) + ); + } + + const orgIds = new Set(rolesData.map((role) => role.orgId)); + + for (const role of rolesData) { + const apiKeyOrgAccess = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, role.orgId!) + ) + ) + .limit(1); + + if (apiKeyOrgAccess.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Key does not have access to organization for role ID ${role.roleId}` + ) + ); + } + } + + if (orgIds.size > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Roles must belong to the same organization" + ) + ); + } + + const orgId = orgIds.values().next().value; + + if (!orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Roles do not have an organization ID" + ) + ); + } + + if (!req.apiKeyOrg) { + // Retrieve the API key's organization link if not already set + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (apiKeyOrgRes.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + return next(); + } catch (error) { + logger.error("Error verifying role access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying role access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts new file mode 100644 index 00000000..d43021ba --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts @@ -0,0 +1,74 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs } from "@server/db/schemas"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeySetResourceUsers( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const userIds = req.body.userIds; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + if (!userIds) { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); + } + + if (userIds.length === 0) { + return next(); + } + + try { + const orgId = req.apiKeyOrg.orgId; + const userOrgsData = await db + .select() + .from(userOrgs) + .where( + and( + inArray(userOrgs.userId, userIds), + eq(userOrgs.orgId, orgId) + ) + ); + + if (userOrgsData.length !== userIds.length) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to one or more specified users" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if key has access to the specified users" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeySiteAccess.ts b/server/middlewares/integration/verifyApiKeySiteAccess.ts new file mode 100644 index 00000000..7d10ddee --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySiteAccess.ts @@ -0,0 +1,94 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { + sites, + apiKeyOrg +} from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeySiteAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const siteId = parseInt( + req.params.siteId || req.body.siteId || req.query.siteId + ); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (isNaN(siteId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID") + ); + } + + const site = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (site.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + if (!site[0].orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Site with ID ${siteId} does not have an organization ID` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, site[0].orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyTargetAccess.ts b/server/middlewares/integration/verifyApiKeyTargetAccess.ts new file mode 100644 index 00000000..bd6e5bc0 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyTargetAccess.ts @@ -0,0 +1,117 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resources, targets, apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyTargetAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const targetId = parseInt(req.params.targetId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (isNaN(targetId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID") + ); + } + + const [target] = await db + .select() + .from(targets) + .where(eq(targets.targetId, targetId)) + .limit(1); + + if (!target) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Target with ID ${targetId} not found` + ) + ); + } + + const resourceId = target.resourceId; + if (!resourceId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Target with ID ${targetId} does not have a resource ID` + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying target access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyUserAccess.ts b/server/middlewares/integration/verifyApiKeyUserAccess.ts new file mode 100644 index 00000000..e1b5d3d3 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyUserAccess.ts @@ -0,0 +1,72 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyUserAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const reqUserId = + req.params.userId || req.body.userId || req.query.userId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!reqUserId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID") + ); + } + + if (!req.apiKeyOrg || !req.apiKeyOrg.orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have organization access" + ) + ); + } + + const orgId = req.apiKeyOrg.orgId; + + const [userOrgRecord] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, reqUserId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + + if (!userOrgRecord) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this user" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if key has access to this user" + ) + ); + } +} diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts new file mode 100644 index 00000000..0bba8f4b --- /dev/null +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -0,0 +1,104 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs, apiKeys, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const apiKeyId = + req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; + const orgId = req.params.orgId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!apiKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [apiKey] = await db + .select() + .from(apiKeys) + .innerJoin(apiKeyOrg, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId)) + .where( + and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!apiKey.apiKeys) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API key with ID ${apiKeyId} not found` + ) + ); + } + + if (!apiKeyOrg.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `API key with ID ${apiKeyId} does not have an organization ID` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, apiKeyOrg.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key access" + ) + ); + } +} diff --git a/server/middlewares/verifyValidLicense.ts b/server/middlewares/verifyValidLicense.ts new file mode 100644 index 00000000..7f4de34a --- /dev/null +++ b/server/middlewares/verifyValidLicense.ts @@ -0,0 +1,33 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import license from "@server/license/license"; + +export async function verifyValidLicense( + req: Request, + res: Response, + next: NextFunction +) { + try { + const unlocked = await license.isUnlocked(); + if (!unlocked) { + return next( + createHttpError(HttpCode.FORBIDDEN, "License is not valid") + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying license" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 43e84e56..4df6cbdd 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -12,5 +12,7 @@ export enum OpenAPITags { Target = "Target", Rule = "Rule", AccessToken = "Access Token", - Idp = "Identity Provider" + Idp = "Identity Provider", + Client = "Client", + ApiKey = "API Key" } diff --git a/server/routers/apiKeys/createOrgApiKey.ts b/server/routers/apiKeys/createOrgApiKey.ts new file mode 100644 index 00000000..2fb9fd20 --- /dev/null +++ b/server/routers/apiKeys/createOrgApiKey.ts @@ -0,0 +1,133 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { NextFunction, Request, Response } from "express"; +import db from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.object({ + name: z.string().min(1).max(255) +}); + +export type CreateOrgApiKeyBody = z.infer; + +export type CreateOrgApiKeyResponse = { + apiKeyId: string; + name: string; + apiKey: string; + lastChars: string; + createdAt: string; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/api-key", + description: "Create a new API key scoped to the organization.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createOrgApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = paramsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { name } = parsedBody.data; + + const apiKeyId = generateId(15); + const apiKey = generateIdFromEntropySize(25); + const apiKeyHash = await hashPassword(apiKey); + const lastChars = apiKey.slice(-4); + const createdAt = moment().toISOString(); + + await db.transaction(async (trx) => { + await trx.insert(apiKeys).values({ + name, + apiKeyId, + apiKeyHash, + createdAt, + lastChars + }); + + await trx.insert(apiKeyOrg).values({ + apiKeyId, + orgId + }); + }); + + try { + return response(res, { + data: { + apiKeyId, + apiKey, + name, + lastChars, + createdAt + }, + success: true, + error: false, + message: "API key created", + status: HttpCode.CREATED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create API key" + ) + ); + } +} diff --git a/server/routers/apiKeys/createRootApiKey.ts b/server/routers/apiKeys/createRootApiKey.ts new file mode 100644 index 00000000..775ae576 --- /dev/null +++ b/server/routers/apiKeys/createRootApiKey.ts @@ -0,0 +1,105 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { NextFunction, Request, Response } from "express"; +import db from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { apiKeyOrg, apiKeys, orgs } from "@server/db/schemas"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; + +const bodySchema = z + .object({ + name: z.string().min(1).max(255) + }) + .strict(); + +export type CreateRootApiKeyBody = z.infer; + +export type CreateRootApiKeyResponse = { + apiKeyId: string; + name: string; + apiKey: string; + lastChars: string; + createdAt: string; +}; + +export async function createRootApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name } = parsedBody.data; + + const apiKeyId = generateId(15); + const apiKey = generateIdFromEntropySize(25); + const apiKeyHash = await hashPassword(apiKey); + const lastChars = apiKey.slice(-4); + const createdAt = moment().toISOString(); + + await db.transaction(async (trx) => { + await trx.insert(apiKeys).values({ + apiKeyId, + name, + apiKeyHash, + createdAt, + lastChars, + isRoot: true + }); + + const allOrgs = await trx.select().from(orgs); + + for (const org of allOrgs) { + await trx.insert(apiKeyOrg).values({ + apiKeyId, + orgId: org.orgId + }); + } + }); + + try { + return response(res, { + data: { + apiKeyId, + name, + apiKey, + lastChars, + createdAt + }, + success: true, + error: false, + message: "API key created", + status: HttpCode.CREATED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create API key" + ) + ); + } +} diff --git a/server/routers/apiKeys/deleteApiKey.ts b/server/routers/apiKeys/deleteApiKey.ts new file mode 100644 index 00000000..2af4ae23 --- /dev/null +++ b/server/routers/apiKeys/deleteApiKey.ts @@ -0,0 +1,81 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/api-key/{apiKeyId}", + description: "Delete an API key.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + await db.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "API key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/deleteOrgApiKey.ts b/server/routers/apiKeys/deleteOrgApiKey.ts new file mode 100644 index 00000000..1834c82c --- /dev/null +++ b/server/routers/apiKeys/deleteOrgApiKey.ts @@ -0,0 +1,104 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty(), + orgId: z.string().nonempty() +}); + +export async function deleteOrgApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId, orgId } = parsedParams.data; + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .innerJoin( + apiKeyOrg, + and( + eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + if (apiKey.apiKeys.isRoot) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot delete root API key" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ); + + const apiKeyOrgs = await db + .select() + .from(apiKeyOrg) + .where(eq(apiKeyOrg.apiKeyId, apiKeyId)); + + if (apiKeyOrgs.length === 0) { + await trx.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId)); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "API removed from organization", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/getApiKey.ts b/server/routers/apiKeys/getApiKey.ts new file mode 100644 index 00000000..bd495bdd --- /dev/null +++ b/server/routers/apiKeys/getApiKey.ts @@ -0,0 +1,81 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +async function query(apiKeyId: string) { + return await db + .select({ + apiKeyId: apiKeys.apiKeyId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + isRoot: apiKeys.isRoot, + name: apiKeys.name + }) + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); +} + +export type GetApiKeyResponse = NonNullable< + Awaited>[0] +>; + +export async function getApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const [apiKey] = await query(apiKeyId); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + return response(res, { + data: apiKey, + success: true, + error: false, + message: "API key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/index.ts b/server/routers/apiKeys/index.ts new file mode 100644 index 00000000..84d4ee68 --- /dev/null +++ b/server/routers/apiKeys/index.ts @@ -0,0 +1,16 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +export * from "./createRootApiKey"; +export * from "./deleteApiKey"; +export * from "./getApiKey"; +export * from "./listApiKeyActions"; +export * from "./listOrgApiKeys"; +export * from "./listApiKeyActions"; +export * from "./listRootApiKeys"; +export * from "./setApiKeyActions"; +export * from "./setApiKeyOrgs"; +export * from "./createOrgApiKey"; +export * from "./deleteOrgApiKey"; diff --git a/server/routers/apiKeys/listApiKeyActions.ts b/server/routers/apiKeys/listApiKeyActions.ts new file mode 100644 index 00000000..0cf694a0 --- /dev/null +++ b/server/routers/apiKeys/listApiKeyActions.ts @@ -0,0 +1,118 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { db } from "@server/db"; +import { actions, apiKeyActions, apiKeyOrg, apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryActions(apiKeyId: string) { + return db + .select({ + actionId: actions.actionId + }) + .from(apiKeyActions) + .where(eq(apiKeyActions.apiKeyId, apiKeyId)) + .innerJoin(actions, eq(actions.actionId, apiKeyActions.actionId)); +} + +export type ListApiKeyActionsResponse = { + actions: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/api-key/{apiKeyId}/actions", + description: + "List all actions set for an API key.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listApiKeyActions( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { limit, offset } = parsedQuery.data; + const { apiKeyId } = parsedParams.data; + + const baseQuery = queryActions(apiKeyId); + + const actionsList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + actions: actionsList, + pagination: { + total: actionsList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/listOrgApiKeys.ts b/server/routers/apiKeys/listOrgApiKeys.ts new file mode 100644 index 00000000..a0169074 --- /dev/null +++ b/server/routers/apiKeys/listOrgApiKeys.ts @@ -0,0 +1,121 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { db } from "@server/db"; +import { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +const paramsSchema = z.object({ + orgId: z.string() +}); + +function queryApiKeys(orgId: string) { + return db + .select({ + apiKeyId: apiKeys.apiKeyId, + orgId: apiKeyOrg.orgId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + name: apiKeys.name + }) + .from(apiKeyOrg) + .where(and(eq(apiKeyOrg.orgId, orgId), eq(apiKeys.isRoot, false))) + .innerJoin(apiKeys, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId)); +} + +export type ListOrgApiKeysResponse = { + apiKeys: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/api-keys", + description: "List all API keys for an organization", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listOrgApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { limit, offset } = parsedQuery.data; + const { orgId } = parsedParams.data; + + const baseQuery = queryApiKeys(orgId); + + const apiKeysList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + apiKeys: apiKeysList, + pagination: { + total: apiKeysList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/listRootApiKeys.ts b/server/routers/apiKeys/listRootApiKeys.ts new file mode 100644 index 00000000..7feca733 --- /dev/null +++ b/server/routers/apiKeys/listRootApiKeys.ts @@ -0,0 +1,90 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { db } from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryApiKeys() { + return db + .select({ + apiKeyId: apiKeys.apiKeyId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + name: apiKeys.name + }) + .from(apiKeys) + .where(eq(apiKeys.isRoot, true)); +} + +export type ListRootApiKeysResponse = { + apiKeys: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listRootApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const baseQuery = queryApiKeys(); + + const apiKeysList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + apiKeys: apiKeysList, + pagination: { + total: apiKeysList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/setApiKeyActions.ts b/server/routers/apiKeys/setApiKeyActions.ts new file mode 100644 index 00000000..187dd114 --- /dev/null +++ b/server/routers/apiKeys/setApiKeyActions.ts @@ -0,0 +1,141 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { actions, apiKeyActions } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const bodySchema = z + .object({ + actionIds: z + .array(z.string().nonempty()) + .transform((v) => Array.from(new Set(v))) + }) + .strict(); + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/api-key/{apiKeyId}/actions", + description: + "Set actions for an API key. This will replace any existing actions.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function setApiKeyActions( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { actionIds: newActionIds } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const actionsExist = await db + .select() + .from(actions) + .where(inArray(actions.actionId, newActionIds)); + + if (actionsExist.length !== newActionIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more actions do not exist" + ) + ); + } + + await db.transaction(async (trx) => { + const existingActions = await trx + .select() + .from(apiKeyActions) + .where(eq(apiKeyActions.apiKeyId, apiKeyId)); + + const existingActionIds = existingActions.map((a) => a.actionId); + + const actionIdsToAdd = newActionIds.filter( + (id) => !existingActionIds.includes(id) + ); + const actionIdsToRemove = existingActionIds.filter( + (id) => !newActionIds.includes(id) + ); + + if (actionIdsToRemove.length > 0) { + await trx + .delete(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, apiKeyId), + inArray(apiKeyActions.actionId, actionIdsToRemove) + ) + ); + } + + if (actionIdsToAdd.length > 0) { + const insertValues = actionIdsToAdd.map((actionId) => ({ + apiKeyId, + actionId + })); + await trx.insert(apiKeyActions).values(insertValues); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "API key actions updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/setApiKeyOrgs.ts b/server/routers/apiKeys/setApiKeyOrgs.ts new file mode 100644 index 00000000..ee0611d3 --- /dev/null +++ b/server/routers/apiKeys/setApiKeyOrgs.ts @@ -0,0 +1,122 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { apiKeyOrg, orgs } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and, inArray } from "drizzle-orm"; + +const bodySchema = z + .object({ + orgIds: z + .array(z.string().nonempty()) + .transform((v) => Array.from(new Set(v))) + }) + .strict(); + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +export async function setApiKeyOrgs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgIds: newOrgIds } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + // make sure all orgs exist + const allOrgs = await db + .select() + .from(orgs) + .where(inArray(orgs.orgId, newOrgIds)); + + if (allOrgs.length !== newOrgIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more orgs do not exist" + ) + ); + } + + await db.transaction(async (trx) => { + const existingOrgs = await trx + .select({ orgId: apiKeyOrg.orgId }) + .from(apiKeyOrg) + .where(eq(apiKeyOrg.apiKeyId, apiKeyId)); + + const existingOrgIds = existingOrgs.map((a) => a.orgId); + + const orgIdsToAdd = newOrgIds.filter( + (id) => !existingOrgIds.includes(id) + ); + const orgIdsToRemove = existingOrgIds.filter( + (id) => !newOrgIds.includes(id) + ); + + if (orgIdsToRemove.length > 0) { + await trx + .delete(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + inArray(apiKeyOrg.orgId, orgIdsToRemove) + ) + ); + } + + if (orgIdsToAdd.length > 0) { + const insertValues = orgIdsToAdd.map((orgId) => ({ + apiKeyId, + orgId + })); + await trx.insert(apiKeyOrg).values(insertValues); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "API key orgs updated successfully", + status: HttpCode.OK + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index a74e2759..d631c377 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -12,6 +12,7 @@ import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as license from "./license"; +import * as apiKeys from "./apiKeys"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -27,7 +28,9 @@ import { verifyUserAccess, getUserOrgs, verifyUserIsServerAdmin, - verifyIsLoggedInUser + verifyIsLoggedInUser, + verifyApiKeyAccess, + verifyValidLicense } from "@server/middlewares"; import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; import { ActionsEnum } from "@server/auth/actions"; @@ -522,6 +525,38 @@ authenticated.post( authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); +authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps); + +authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); + +authenticated.put( + "/idp/:idpId/org/:orgId", + verifyValidLicense, + verifyUserIsServerAdmin, + idp.createIdpOrgPolicy +); + +authenticated.post( + "/idp/:idpId/org/:orgId", + verifyValidLicense, + verifyUserIsServerAdmin, + idp.updateIdpOrgPolicy +); + +authenticated.delete( + "/idp/:idpId/org/:orgId", + verifyValidLicense, + verifyUserIsServerAdmin, + idp.deleteIdpOrgPolicy +); + +authenticated.get( + "/idp/:idpId/org", + verifyValidLicense, + verifyUserIsServerAdmin, + idp.listIdpOrgPolicies +); + authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); @@ -549,6 +584,100 @@ authenticated.post( license.recheckStatus ); +authenticated.get( + `/api-key/:apiKeyId`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.getApiKey +); + +authenticated.put( + `/api-key`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.createRootApiKey +); + +authenticated.delete( + `/api-key/:apiKeyId`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.deleteApiKey +); + +authenticated.get( + `/api-keys`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.listRootApiKeys +); + +authenticated.get( + `/api-key/:apiKeyId/actions`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.listApiKeyActions +); + +authenticated.post( + `/api-key/:apiKeyId/actions`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.setApiKeyActions +); + +authenticated.get( + `/org/:orgId/api-keys`, + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listApiKeys), + apiKeys.listOrgApiKeys +); + +authenticated.post( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyValidLicense, + verifyOrgAccess, + verifyApiKeyAccess, + verifyUserHasAction(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions +); + +authenticated.get( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyValidLicense, + verifyOrgAccess, + verifyApiKeyAccess, + verifyUserHasAction(ActionsEnum.listApiKeyActions), + apiKeys.listApiKeyActions +); + +authenticated.put( + `/org/:orgId/api-key`, + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey +); + +authenticated.delete( + `/org/:orgId/api-key/:apiKeyId`, + verifyValidLicense, + verifyOrgAccess, + verifyApiKeyAccess, + verifyUserHasAction(ActionsEnum.deleteApiKey), + apiKeys.deleteOrgApiKey +); + +authenticated.get( + `/org/:orgId/api-key/:apiKeyId`, + verifyValidLicense, + verifyOrgAccess, + verifyApiKeyAccess, + verifyUserHasAction(ActionsEnum.getApiKey), + apiKeys.getApiKey +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/routers/idp/createIdpOrgPolicy.ts b/server/routers/idp/createIdpOrgPolicy.ts new file mode 100644 index 00000000..ae5acce4 --- /dev/null +++ b/server/routers/idp/createIdpOrgPolicy.ts @@ -0,0 +1,129 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import config from "@server/lib/config"; +import { eq, and } from "drizzle-orm"; +import { idp, idpOrg } from "@server/db/schemas"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number(), + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + roleMapping: z.string().optional(), + orgMapping: z.string().optional() + }) + .strict(); + +export type CreateIdpOrgPolicyResponse = {}; + +registry.registerPath({ + method: "put", + path: "/idp/{idpId}/org/{orgId}", + description: "Create an IDP policy for an existing IDP on an organization.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createIdpOrgPolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + const { roleMapping, orgMapping } = parsedBody.data; + + const [existing] = await db + .select() + .from(idp) + .leftJoin( + idpOrg, + and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)) + ) + .where(eq(idp.idpId, idpId)); + + if (!existing?.idp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) + ); + } + + if (existing.idpOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP org policy already exists." + ) + ); + } + + await db.insert(idpOrg).values({ + idpId, + orgId, + roleMapping, + orgMapping + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Idp created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 910a0953..d663afef 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -7,7 +7,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { idp, idpOidcConfig } from "@server/db/schemas"; +import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; diff --git a/server/routers/idp/deleteIdp.ts b/server/routers/idp/deleteIdp.ts index 79edd547..ac84c4f7 100644 --- a/server/routers/idp/deleteIdp.ts +++ b/server/routers/idp/deleteIdp.ts @@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { idp, idpOidcConfig } from "@server/db/schemas"; +import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; @@ -67,6 +67,11 @@ export async function deleteIdp( .delete(idpOidcConfig) .where(eq(idpOidcConfig.idpId, idpId)); + // Delete IDP-org mappings + await trx + .delete(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + // Delete the IDP itself await trx .delete(idp) diff --git a/server/routers/idp/deleteIdpOrgPolicy.ts b/server/routers/idp/deleteIdpOrgPolicy.ts new file mode 100644 index 00000000..5c41c958 --- /dev/null +++ b/server/routers/idp/deleteIdpOrgPolicy.ts @@ -0,0 +1,95 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { idp, idpOrg } from "@server/db/schemas"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number(), + orgId: z.string() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/idp/{idpId}/org/{orgId}", + description: "Create an OIDC IdP for an organization.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteIdpOrgPolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + + const [existing] = await db + .select() + .from(idp) + .leftJoin(idpOrg, eq(idpOrg.orgId, orgId)) + .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))); + + if (!existing.idp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) + ); + } + + if (!existing.idpOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A policy for this IDP and org does not exist." + ) + ); + } + + await db + .delete(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: null, + success: true, + error: false, + message: "Policy deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 6d111451..4a62cac2 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { idp, idpOidcConfig } from "@server/db/schemas"; +import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas"; import { and, eq } from "drizzle-orm"; import * as arctic from "arctic"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; @@ -27,6 +27,10 @@ const bodySchema = z }) .strict(); +const ensureTrailingSlash = (url: string): string => { + return url.endsWith('/') ? url : `${url}/`; +}; + export type GenerateOidcUrlResponse = { redirectUrl: string; }; @@ -106,12 +110,13 @@ export async function generateOidcUrl( const codeVerifier = arctic.generateCodeVerifier(); const state = arctic.generateState(); const url = client.createAuthorizationURLWithPKCE( - existingIdp.idpOidcConfig.authUrl, + ensureTrailingSlash(existingIdp.idpOidcConfig.authUrl), state, arctic.CodeChallengeMethod.S256, codeVerifier, parsedScopes ); + logger.debug("Generated OIDC URL", { url }); const stateJwt = jsonwebtoken.sign( { diff --git a/server/routers/idp/index.ts b/server/routers/idp/index.ts index 185effde..f0dcf02e 100644 --- a/server/routers/idp/index.ts +++ b/server/routers/idp/index.ts @@ -5,3 +5,7 @@ export * from "./listIdps"; export * from "./generateOidcUrl"; export * from "./validateOidcCallback"; export * from "./getIdp"; +export * from "./createIdpOrgPolicy"; +export * from "./deleteIdpOrgPolicy"; +export * from "./listIdpOrgPolicies"; +export * from "./updateIdpOrgPolicy"; diff --git a/server/routers/idp/listIdpOrgPolicies.ts b/server/routers/idp/listIdpOrgPolicies.ts new file mode 100644 index 00000000..9ff9c97a --- /dev/null +++ b/server/routers/idp/listIdpOrgPolicies.ts @@ -0,0 +1,121 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idpOrg } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + idpId: z.coerce.number() +}); + +const querySchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +async function query(idpId: number, limit: number, offset: number) { + const res = await db + .select() + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)) + .limit(limit) + .offset(offset); + return res; +} + +export type ListIdpOrgPoliciesResponse = { + policies: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/idp/{idpId}/org", + description: "List all org policies on an IDP.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listIdpOrgPolicies( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { idpId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const list = await query(idpId, limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + return response(res, { + data: { + policies: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Policies retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts index 76d0be87..a723ee05 100644 --- a/server/routers/idp/listIdps.ts +++ b/server/routers/idp/listIdps.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { idp } from "@server/db/schemas"; +import { domains, idp, orgDomains, users, idpOrg } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -33,8 +33,10 @@ async function query(limit: number, offset: number) { idpId: idp.idpId, name: idp.name, type: idp.type, + orgCount: sql`count(${idpOrg.orgId})` }) .from(idp) + .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) .groupBy(idp.idpId) .limit(limit) .offset(offset); @@ -46,6 +48,7 @@ export type ListIdpsResponse = { idpId: number; name: string; type: string; + orgCount: number; }>; pagination: { total: number; diff --git a/server/routers/idp/oidcAutoProvision.ts b/server/routers/idp/oidcAutoProvision.ts new file mode 100644 index 00000000..7861fc41 --- /dev/null +++ b/server/routers/idp/oidcAutoProvision.ts @@ -0,0 +1,233 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { + createSession, + generateId, + generateSessionToken, + serializeSessionCookie +} from "@server/auth/sessions/app"; +import db from "@server/db"; +import { Idp, idpOrg, orgs, roles, User, userOrgs, users } from "@server/db/schemas"; +import logger from "@server/logger"; +import { UserType } from "@server/types/UserTypes"; +import { eq, and, inArray } from "drizzle-orm"; +import jmespath from "jmespath"; +import { Request, Response } from "express"; + +export async function oidcAutoProvision({ + idp, + claims, + existingUser, + userIdentifier, + email, + name, + req, + res +}: { + idp: Idp; + claims: any; + existingUser?: User; + userIdentifier: string; + email?: string; + name?: string; + req: Request; + res: Response; +}) { + const allOrgs = await db.select().from(orgs); + + const defaultRoleMapping = idp.defaultRoleMapping; + const defaultOrgMapping = idp.defaultOrgMapping; + + let userOrgInfo: { orgId: string; roleId: number }[] = []; + for (const org of allOrgs) { + const [idpOrgRes] = await db + .select() + .from(idpOrg) + .where( + and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, org.orgId)) + ); + + let roleId: number | undefined = undefined; + + const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; + const hydratedOrgMapping = hydrateOrgMapping(orgMapping, org.orgId); + + if (hydratedOrgMapping) { + logger.debug("Hydrated Org Mapping", { + hydratedOrgMapping + }); + const orgId = jmespath.search(claims, hydratedOrgMapping); + logger.debug("Extraced Org ID", { orgId }); + if (orgId !== true && orgId !== org.orgId) { + // user not allowed to access this org + continue; + } + } + + const roleMapping = idpOrgRes?.roleMapping || defaultRoleMapping; + if (roleMapping) { + logger.debug("Role Mapping", { roleMapping }); + const roleName = jmespath.search(claims, roleMapping); + + if (!roleName) { + logger.error("Role name not found in the ID token", { + roleName + }); + continue; + } + + const [roleRes] = await db + .select() + .from(roles) + .where( + and(eq(roles.orgId, org.orgId), eq(roles.name, roleName)) + ); + + if (!roleRes) { + logger.error("Role not found", { + orgId: org.orgId, + roleName + }); + continue; + } + + roleId = roleRes.roleId; + + userOrgInfo.push({ + orgId: org.orgId, + roleId + }); + } + } + + logger.debug("User org info", { userOrgInfo }); + + let existingUserId = existingUser?.userId; + + // sync the user with the orgs and roles + await db.transaction(async (trx) => { + let userId = existingUser?.userId; + + // create user if not exists + if (!existingUser) { + userId = generateId(15); + + await trx.insert(users).values({ + userId, + username: userIdentifier, + email: email || null, + name: name || null, + type: UserType.OIDC, + idpId: idp.idpId, + emailVerified: true, // OIDC users are always verified + dateCreated: new Date().toISOString() + }); + } else { + // set the name and email + await trx + .update(users) + .set({ + username: userIdentifier, + email: email || null, + name: name || null + }) + .where(eq(users.userId, userId!)); + } + + existingUserId = userId; + + // get all current user orgs + const currentUserOrgs = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.userId, userId!)); + + // Delete orgs that are no longer valid + const orgsToDelete = currentUserOrgs.filter( + (currentOrg) => + !userOrgInfo.some((newOrg) => newOrg.orgId === currentOrg.orgId) + ); + + if (orgsToDelete.length > 0) { + await trx.delete(userOrgs).where( + and( + eq(userOrgs.userId, userId!), + inArray( + userOrgs.orgId, + orgsToDelete.map((org) => org.orgId) + ) + ) + ); + } + + // Update roles for existing orgs where the role has changed + const orgsToUpdate = currentUserOrgs.filter((currentOrg) => { + const newOrg = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + return newOrg && newOrg.roleId !== currentOrg.roleId; + }); + + if (orgsToUpdate.length > 0) { + for (const org of orgsToUpdate) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === org.orgId + ); + if (newRole) { + await trx + .update(userOrgs) + .set({ roleId: newRole.roleId }) + .where( + and( + eq(userOrgs.userId, userId!), + eq(userOrgs.orgId, org.orgId) + ) + ); + } + } + } + + // Add new orgs that don't exist yet + const orgsToAdd = userOrgInfo.filter( + (newOrg) => + !currentUserOrgs.some( + (currentOrg) => currentOrg.orgId === newOrg.orgId + ) + ); + + if (orgsToAdd.length > 0) { + await trx.insert(userOrgs).values( + orgsToAdd.map((org) => ({ + userId: userId!, + orgId: org.orgId, + roleId: org.roleId, + dateCreated: new Date().toISOString() + })) + ); + } + }); + + const token = generateSessionToken(); + const sess = await createSession(token, existingUserId!); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + token, + isSecure, + new Date(sess.expiresAt) + ); + + res.appendHeader("Set-Cookie", cookie); +} + +function hydrateOrgMapping( + orgMapping: string | null, + orgId: string +): string | undefined { + if (!orgMapping) { + return undefined; + } + return orgMapping.split("{{orgId}}").join(orgId); +} diff --git a/server/routers/idp/updateIdpOrgPolicy.ts b/server/routers/idp/updateIdpOrgPolicy.ts new file mode 100644 index 00000000..6f8580ac --- /dev/null +++ b/server/routers/idp/updateIdpOrgPolicy.ts @@ -0,0 +1,131 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq, and } from "drizzle-orm"; +import { idp, idpOrg } from "@server/db/schemas"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number(), + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + roleMapping: z.string().optional(), + orgMapping: z.string().optional() + }) + .strict(); + +export type UpdateIdpOrgPolicyResponse = {}; + +registry.registerPath({ + method: "post", + path: "/idp/{idpId}/org/{orgId}", + description: "Update an IDP org policy.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateIdpOrgPolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + const { roleMapping, orgMapping } = parsedBody.data; + + // Check if IDP and policy exist + const [existing] = await db + .select() + .from(idp) + .leftJoin( + idpOrg, + and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)) + ) + .where(eq(idp.idpId, idpId)); + + if (!existing?.idp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) + ); + } + + if (!existing.idpOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A policy for this IDP and org does not exist." + ) + ); + } + + // Update the policy + await db + .update(idpOrg) + .set({ + roleMapping, + orgMapping + }) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Policy updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index a495adb5..d24e319e 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -42,7 +42,7 @@ export type UpdateIdpResponse = { registry.registerPath({ method: "post", - path: "/idp/:idpId/oidc", + path: "/idp/{idpId}/oidc", description: "Update an OIDC IdP.", tags: [OpenAPITags.Idp], request: { diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 006c14a4..7f4ff784 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -1,24 +1,30 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; +import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { idp, idpOidcConfig, users } from "@server/db/schemas"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import * as arctic from "arctic"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import jmespath from "jmespath"; import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; -import { decrypt } from "@server/lib/crypto"; import { createSession, generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; -import { response } from "@server/lib"; +import { decrypt } from "@server/lib/crypto"; +import { oidcAutoProvision } from "./oidcAutoProvision"; +import license from "@server/license/license"; + +const ensureTrailingSlash = (url: string): string => { + return url.endsWith('/') ? url : `${url}/`; +}; const paramsSchema = z .object({ @@ -148,7 +154,7 @@ export async function validateOidcCallback( } const tokens = await client.validateAuthorizationCode( - existingIdp.idpOidcConfig.tokenUrl, + ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), code, codeVerifier ); @@ -204,12 +210,24 @@ export async function validateOidcCallback( ); if (existingIdp.idp.autoProvision) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Auto provisioning is not supported" - ) - ); + if (!(await license.isUnlocked())) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Auto-provisioning is not available" + ) + ); + } + await oidcAutoProvision({ + idp: existingIdp.idp, + userIdentifier, + email, + name, + claims, + existingUser, + req, + res + }); } else { if (!existingUser) { return next( diff --git a/server/routers/integration.ts b/server/routers/integration.ts new file mode 100644 index 00000000..40ab9aa9 --- /dev/null +++ b/server/routers/integration.ts @@ -0,0 +1,499 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import * as site from "./site"; +import * as org from "./org"; +import * as resource from "./resource"; +import * as domain from "./domain"; +import * as target from "./target"; +import * as user from "./user"; +import * as role from "./role"; +// import * as client from "./client"; +import * as accessToken from "./accessToken"; +import * as apiKeys from "./apiKeys"; +import * as idp from "./idp"; +import { + verifyApiKey, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction, + verifyApiKeySiteAccess, + verifyApiKeyResourceAccess, + verifyApiKeyTargetAccess, + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyAccessTokenAccess, + verifyApiKeyIsRoot +} from "@server/middlewares"; +import HttpCode from "@server/types/HttpCode"; +import { Router } from "express"; +import { ActionsEnum } from "@server/auth/actions"; + +export const unauthenticated = Router(); + +unauthenticated.get("/", (_, res) => { + res.status(HttpCode.OK).json({ message: "Healthy" }); +}); + +export const authenticated = Router(); +authenticated.use(verifyApiKey); + +authenticated.get( + "/org/checkId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.checkOrgId), + org.checkId +); + +authenticated.put( + "/org", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createOrg), + org.createOrg +); + +authenticated.get( + "/orgs", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listOrgs), + org.listOrgs +); // TODO we need to check the orgs here + +authenticated.get( + "/org/:orgId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrg), + org.getOrg +); + +authenticated.post( + "/org/:orgId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.updateOrg), + org.updateOrg +); + +authenticated.delete( + "/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteOrg), + org.deleteOrg +); + +authenticated.put( + "/org/:orgId/site", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createSite), + site.createSite +); + +authenticated.get( + "/org/:orgId/sites", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listSites), + site.listSites +); + +authenticated.get( + "/org/:orgId/site/:niceId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getSite), + site.getSite +); + +authenticated.get( + "/org/:orgId/pick-site-defaults", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createSite), + site.pickSiteDefaults +); + +authenticated.get( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.getSite), + site.getSite +); + +authenticated.post( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.updateSite), + site.updateSite +); + +authenticated.delete( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.deleteSite), + site.deleteSite +); + +authenticated.put( + "/org/:orgId/site/:siteId/resource", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createResource), + resource.createResource +); + +authenticated.get( + "/site/:siteId/resources", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.listResources), + resource.listResources +); + +authenticated.get( + "/org/:orgId/resources", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listResources), + resource.listResources +); + +authenticated.get( + "/org/:orgId/domains", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listOrgDomains), + domain.listDomains +); + +authenticated.post( + "/org/:orgId/create-invite", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.inviteUser), + user.inviteUser +); + +authenticated.get( + "/resource/:resourceId/roles", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceRoles), + resource.listResourceRoles +); + +authenticated.get( + "/resource/:resourceId/users", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceUsers), + resource.listResourceUsers +); + +authenticated.get( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResource), + resource.getResource +); + +authenticated.post( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.updateResource +); + +authenticated.delete( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResource), + resource.deleteResource +); + +authenticated.put( + "/resource/:resourceId/target", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.createTarget), + target.createTarget +); + +authenticated.get( + "/resource/:resourceId/targets", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listTargets), + target.listTargets +); + +authenticated.put( + "/resource/:resourceId/rule", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.createResourceRule), + resource.createResourceRule +); + +authenticated.get( + "/resource/:resourceId/rules", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceRules), + resource.listResourceRules +); + +authenticated.post( + "/resource/:resourceId/rule/:ruleId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResourceRule), + resource.updateResourceRule +); + +authenticated.delete( + "/resource/:resourceId/rule/:ruleId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule +); + +authenticated.get( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.getTarget), + target.getTarget +); + +authenticated.post( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.updateTarget), + target.updateTarget +); + +authenticated.delete( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.deleteTarget), + target.deleteTarget +); + +authenticated.put( + "/org/:orgId/role", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createRole), + role.createRole +); + +authenticated.get( + "/org/:orgId/roles", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listRoles), + role.listRoles +); + +authenticated.delete( + "/role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.deleteRole), + role.deleteRole +); + +authenticated.post( + "/role/:roleId/add/:userId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyApiKeyHasAction(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.post( + "/resource/:resourceId/roles", + verifyApiKeyResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + resource.setResourceRoles +); + +authenticated.post( + "/resource/:resourceId/users", + verifyApiKeyResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + resource.setResourceUsers +); + +authenticated.post( + `/resource/:resourceId/password`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourcePassword), + resource.setResourcePassword +); + +authenticated.post( + `/resource/:resourceId/pincode`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourcePincode), + resource.setResourcePincode +); + +authenticated.post( + `/resource/:resourceId/whitelist`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist +); + +authenticated.get( + `/resource/:resourceId/whitelist`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResourceWhitelist), + resource.getResourceWhitelist +); + +authenticated.post( + `/resource/:resourceId/transfer`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.transferResource +); + +authenticated.post( + `/resource/:resourceId/access-token`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken +); + +authenticated.delete( + `/access-token/:accessTokenId`, + verifyApiKeyAccessTokenAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken +); + +authenticated.get( + `/org/:orgId/access-tokens`, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listAccessTokens), + accessToken.listAccessTokens +); + +authenticated.get( + `/resource/:resourceId/access-tokens`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listAccessTokens), + accessToken.listAccessTokens +); + +authenticated.get( + "/org/:orgId/user/:userId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrgUser), + user.getOrgUser +); + +authenticated.get( + "/org/:orgId/users", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listUsers), + user.listUsers +); + +authenticated.delete( + "/org/:orgId/user/:userId", + verifyApiKeyOrgAccess, + verifyApiKeyUserAccess, + verifyApiKeyHasAction(ActionsEnum.removeUser), + user.removeUserOrg +); + +// authenticated.put( +// "/newt", +// verifyApiKeyHasAction(ActionsEnum.createNewt), +// newt.createNewt +// ); + +authenticated.get( + `/org/:orgId/api-keys`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listApiKeys), + apiKeys.listOrgApiKeys +); + +authenticated.post( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions +); + +authenticated.get( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listApiKeyActions), + apiKeys.listApiKeyActions +); + +authenticated.put( + `/org/:orgId/api-key`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey +); + +authenticated.delete( + `/org/:orgId/api-key/:apiKeyId`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteApiKey), + apiKeys.deleteApiKey +); + +authenticated.put( + "/idp/oidc", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createIdp), + idp.createOidcIdp +); + +authenticated.post( + "/idp/:idpId/oidc", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.updateIdp), + idp.updateOidcIdp +); + +authenticated.delete( + "/idp/:idpId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteIdp), + idp.deleteIdp +); + +authenticated.get( + "/idp", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listIdps), + idp.listIdps +); + +authenticated.get( + "/idp/:idpId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.getIdp), + idp.getIdp +); + +authenticated.put( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createIdpOrg), + idp.createIdpOrgPolicy +); + +authenticated.post( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), + idp.updateIdpOrgPolicy +); + +authenticated.delete( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), + idp.deleteIdpOrgPolicy +); + +authenticated.get( + "/idp/:idpId/org", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listIdpOrgs), + idp.listIdpOrgPolicies +); diff --git a/server/routers/license/deleteLicenseKey.ts b/server/routers/license/deleteLicenseKey.ts index db98e78a..bea7f9ad 100644 --- a/server/routers/license/deleteLicenseKey.ts +++ b/server/routers/license/deleteLicenseKey.ts @@ -14,6 +14,8 @@ import db from "@server/db"; import { eq } from "drizzle-orm"; import { licenseKey } from "@server/db/schemas"; import license, { LicenseStatus } from "@server/license/license"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; const paramsSchema = z .object({ diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 64d0871f..43650480 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -31,7 +31,7 @@ const listOrgsSchema = z.object({ registry.registerPath({ method: "get", - path: "/user/:userId/orgs", + path: "/user/{userId}/orgs", description: "List all organizations for a user.", tags: [OpenAPITags.Org, OpenAPITags.User], request: { diff --git a/server/setup/index.ts b/server/setup/index.ts index 51cb358d..b93af2aa 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -3,11 +3,9 @@ import { copyInConfig } from "./copyInConfig"; import { setupServerAdmin } from "./setupServerAdmin"; import logger from "@server/logger"; import { clearStaleData } from "./clearStaleData"; -import { setHostMeta } from "./setHostMeta"; export async function runSetupFunctions() { try { - await setHostMeta(); await copyInConfig(); // copy in the config to the db as needed await setupServerAdmin(); await ensureActions(); // make sure all of the actions are in the db and the roles diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index dbeaeea2..753ed6a7 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -20,6 +20,7 @@ import m16 from "./scripts/1.0.0"; import m17 from "./scripts/1.1.0"; import m18 from "./scripts/1.2.0"; import m19 from "./scripts/1.3.0"; +import { setHostMeta } from "./setHostMeta"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA diff --git a/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx b/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx new file mode 100644 index 00000000..69fe7176 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx @@ -0,0 +1,33 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { DataTable } from "@app/components/ui/data-table"; +import { ColumnDef } from "@tanstack/react-table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + addApiKey?: () => void; +} + +export function OrgApiKeysDataTable({ + addApiKey, + columns, + data +}: DataTableProps) { + return ( + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx b/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx new file mode 100644 index 00000000..89e47842 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx @@ -0,0 +1,204 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { OrgApiKeysDataTable } from "./OrgApiKeysDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import moment from "moment"; + +export type OrgApiKeyRow = { + id: string; + key: string; + name: string; + createdAt: string; +}; + +type OrgApiKeyTableProps = { + apiKeys: OrgApiKeyRow[]; + orgId: string; +}; + +export default function OrgApiKeysTable({ + apiKeys, + orgId +}: OrgApiKeyTableProps) { + const router = useRouter(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(apiKeys); + + const api = createApiClient(useEnvContext()); + + const deleteSite = (apiKeyId: string) => { + api.delete(`/org/${orgId}/api-key/${apiKeyId}`) + .catch((e) => { + console.error("Error deleting API key", e); + toast({ + variant: "destructive", + title: "Error deleting API key", + description: formatAxiosError(e, "Error deleting API key") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== apiKeyId); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const apiKeyROw = row.original; + const router = useRouter(); + + return ( + + + + + + { + setSelected(apiKeyROw); + }} + > + View settings + + { + setSelected(apiKeyROw); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "key", + header: "Key", + cell: ({ row }) => { + const r = row.original; + return {r.key}; + } + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => { + const r = row.original; + return {moment(r.createdAt).format("lll")} ; + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + +
+ ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

+ Are you sure you want to remove the API key{" "} + {selected?.name || selected?.id} from the + organization? +

+ +

+ + Once removed, the API key will no longer be + able to be used. + +

+ +

+ To confirm, please type the name of the API key + below. +

+
+ } + buttonText="Confirm Delete API Key" + onConfirm={async () => deleteSite(selected!.id)} + string={selected.name} + title="Delete API Key" + /> + )} + + { + router.push(`/${orgId}/settings/api-keys/create`); + }} + /> + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx new file mode 100644 index 00000000..a4c13c9a --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx @@ -0,0 +1,62 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import Link from "next/link"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import { GetApiKeyResponse } from "@server/routers/apiKeys"; +import ApiKeyProvider from "@app/providers/ApiKeyProvider"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ apiKeyId: string; orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let apiKey = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/api-key/${params.apiKeyId}`, + await authCookieHeader() + ); + apiKey = res.data.data; + } catch (e) { + console.log(e); + redirect(`/${params.orgId}/settings/api-keys`); + } + + const navItems = [ + { + title: "Permissions", + href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions" + } + ]; + + return ( + <> + + + + {children} + + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx new file mode 100644 index 00000000..7df37cd6 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx @@ -0,0 +1,13 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { redirect } from "next/navigation"; + +export default async function ApiKeysPage(props: { + params: Promise<{ orgId: string; apiKeyId: string }>; +}) { + const params = await props.params; + redirect(`/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions`); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx new file mode 100644 index 00000000..d1e6f518 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx @@ -0,0 +1,138 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { ListApiKeyActionsResponse } from "@server/routers/apiKeys"; +import { AxiosResponse } from "axios"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId, apiKeyId } = useParams(); + + const [loadingPage, setLoadingPage] = useState(true); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + const [loadingSavePermissions, setLoadingSavePermissions] = + useState(false); + + useEffect(() => { + async function load() { + setLoadingPage(true); + + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/api-key/${apiKeyId}/actions`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error loading API key actions", + description: formatAxiosError( + e, + "Error loading API key actions" + ) + }); + }); + + if (res && res.status === 200) { + const data = res.data.data; + for (const action of data.actions) { + setSelectedPermissions((prev) => ({ + ...prev, + [action.actionId]: true + })); + } + } + + setLoadingPage(false); + } + + load(); + }, []); + + async function savePermissions() { + setLoadingSavePermissions(true); + + const actionsRes = await api + .post(`/org/${orgId}/api-key/${apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes && actionsRes.status === 200) { + toast({ + title: "Permissions updated", + description: "The permissions have been updated." + }); + } + + setLoadingSavePermissions(false); + } + + return ( + <> + {!loadingPage && ( + + + + + Permissions + + + Determine what this API key can do + + + + + + + + + + + + )} + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/create/page.tsx b/src/app/[orgId]/settings/api-keys/create/page.tsx new file mode 100644 index 00000000..3ede2ac0 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/create/page.tsx @@ -0,0 +1,412 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { InfoIcon } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import Link from "next/link"; +import { + CreateOrgApiKeyBody, + CreateOrgApiKeyResponse +} from "@server/routers/apiKeys"; +import { ApiKey } from "@server/db/schemas"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import moment from "moment"; +import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox"; +import CopyTextBox from "@app/components/CopyTextBox"; +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; + +const createFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters." + }) + .max(255, { + message: "Name must not be longer than 255 characters." + }) +}); + +type CreateFormValues = z.infer; + +const copiedFormSchema = z + .object({ + copied: z.boolean() + }) + .refine( + (data) => { + return data.copied; + }, + { + message: "You must confirm that you have copied the API key.", + path: ["copied"] + } + ); + +type CopiedFormValues = z.infer; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + + const [loadingPage, setLoadingPage] = useState(true); + const [createLoading, setCreateLoading] = useState(false); + const [apiKey, setApiKey] = useState(null); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + + const form = useForm({ + resolver: zodResolver(createFormSchema), + defaultValues: { + name: "" + } + }); + + const copiedForm = useForm({ + resolver: zodResolver(copiedFormSchema), + defaultValues: { + copied: false + } + }); + + async function onSubmit(data: CreateFormValues) { + setCreateLoading(true); + + let payload: CreateOrgApiKeyBody = { + name: data.name + }; + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/api-key/`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating API key", + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + + console.log({ + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }); + + const actionsRes = await api + .post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes) { + setApiKey(data); + } + } + + setCreateLoading(false); + } + + async function onCopiedSubmit(data: CopiedFormValues) { + if (!data.copied) { + return; + } + + router.push(`/${orgId}/settings/api-keys`); + } + + const formatLabel = (str: string) => { + return str + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/^./, (char) => char.toUpperCase()); + }; + + useEffect(() => { + const load = async () => { + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + {!apiKey && ( + <> + + + + API Key Information + + + + +
+ + ( + + + Name + + + + + + + )} + /> + + +
+
+
+ + + + + Permissions + + + Determine what this API key can do + + + + + + + + )} + + {apiKey && ( + + + + Your API Key + + + + + + + Name + + + + + + + + Created + + + {moment( + apiKey.createdAt + ).format("lll")} + + + + + + + + Save Your API Key + + + You will only be able to see this + once. Make sure to copy it to a + secure place. + + + +

+ Your API key is: +

+ + + +
+ + ( + +
+ { + copiedForm.setValue( + "copied", + e as boolean + ); + }} + /> + +
+ +
+ )} + /> + + +
+
+ )} +
+ +
+ {!apiKey && ( + + )} + {!apiKey && ( + + )} + + {apiKey && ( + + )} +
+
+ )} + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/page.tsx b/src/app/[orgId]/settings/api-keys/page.tsx new file mode 100644 index 00000000..ef1e3dd1 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/page.tsx @@ -0,0 +1,49 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable"; +import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; + +type ApiKeyPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ApiKeysPage(props: ApiKeyPageProps) { + const params = await props.params; + let apiKeys: ListOrgApiKeysResponse["apiKeys"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/api-keys`, + await authCookieHeader() + ); + apiKeys = res.data.data.apiKeys; + } catch (e) {} + + const rows: OrgApiKeyRow[] = apiKeys.map((key) => { + return { + name: key.name, + id: key.apiKeyId, + key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`, + createdAt: key.createdAt + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/admin/api-keys/ApiKeysDataTable.tsx b/src/app/admin/api-keys/ApiKeysDataTable.tsx new file mode 100644 index 00000000..f65949a4 --- /dev/null +++ b/src/app/admin/api-keys/ApiKeysDataTable.tsx @@ -0,0 +1,58 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useState } from "react"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Plus, Search } from "lucide-react"; +import { DataTable } from "@app/components/ui/data-table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + addApiKey?: () => void; +} + +export function ApiKeysDataTable({ + addApiKey, + columns, + data +}: DataTableProps) { + return ( + + ); +} diff --git a/src/app/admin/api-keys/ApiKeysTable.tsx b/src/app/admin/api-keys/ApiKeysTable.tsx new file mode 100644 index 00000000..c44d43f3 --- /dev/null +++ b/src/app/admin/api-keys/ApiKeysTable.tsx @@ -0,0 +1,199 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import moment from "moment"; +import { ApiKeysDataTable } from "./ApiKeysDataTable"; + +export type ApiKeyRow = { + id: string; + key: string; + name: string; + createdAt: string; +}; + +type ApiKeyTableProps = { + apiKeys: ApiKeyRow[]; +}; + +export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { + const router = useRouter(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(apiKeys); + + const api = createApiClient(useEnvContext()); + + const deleteSite = (apiKeyId: string) => { + api.delete(`/api-key/${apiKeyId}`) + .catch((e) => { + console.error("Error deleting API key", e); + toast({ + variant: "destructive", + title: "Error deleting API key", + description: formatAxiosError(e, "Error deleting API key") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== apiKeyId); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const apiKeyROw = row.original; + const router = useRouter(); + + return ( + + + + + + { + setSelected(apiKeyROw); + }} + > + View settings + + { + setSelected(apiKeyROw); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "key", + header: "Key", + cell: ({ row }) => { + const r = row.original; + return {r.key}; + } + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => { + const r = row.original; + return {moment(r.createdAt).format("lll")} ; + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + +
+ ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

+ Are you sure you want to remove the API key{" "} + {selected?.name || selected?.id}? +

+ +

+ + Once removed, the API key will no longer be + able to be used. + +

+ +

+ To confirm, please type the name of the API key + below. +

+
+ } + buttonText="Confirm Delete API Key" + onConfirm={async () => deleteSite(selected!.id)} + string={selected.name} + title="Delete API Key" + /> + )} + + { + router.push(`/admin/api-keys/create`); + }} + /> + + ); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/layout.tsx b/src/app/admin/api-keys/[apiKeyId]/layout.tsx new file mode 100644 index 00000000..be3147ea --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/layout.tsx @@ -0,0 +1,62 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import Link from "next/link"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import { GetApiKeyResponse } from "@server/routers/apiKeys"; +import ApiKeyProvider from "@app/providers/ApiKeyProvider"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ apiKeyId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let apiKey = null; + try { + const res = await internal.get>( + `/api-key/${params.apiKeyId}`, + await authCookieHeader() + ); + apiKey = res.data.data; + } catch (e) { + console.error(e); + redirect(`/admin/api-keys`); + } + + const navItems = [ + { + title: "Permissions", + href: "/admin/api-keys/{apiKeyId}/permissions" + } + ]; + + return ( + <> + + + + {children} + + + ); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/page.tsx b/src/app/admin/api-keys/[apiKeyId]/page.tsx new file mode 100644 index 00000000..b0e4c3e5 --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/page.tsx @@ -0,0 +1,13 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { redirect } from "next/navigation"; + +export default async function ApiKeysPage(props: { + params: Promise<{ apiKeyId: string }>; +}) { + const params = await props.params; + redirect(`/admin/api-keys/${params.apiKeyId}/permissions`); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx b/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx new file mode 100644 index 00000000..c468c139 --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx @@ -0,0 +1,139 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { ListApiKeyActionsResponse } from "@server/routers/apiKeys"; +import { AxiosResponse } from "axios"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { apiKeyId } = useParams(); + + const [loadingPage, setLoadingPage] = useState(true); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + const [loadingSavePermissions, setLoadingSavePermissions] = + useState(false); + + useEffect(() => { + async function load() { + setLoadingPage(true); + + const res = await api + .get< + AxiosResponse + >(`/api-key/${apiKeyId}/actions`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error loading API key actions", + description: formatAxiosError( + e, + "Error loading API key actions" + ) + }); + }); + + if (res && res.status === 200) { + const data = res.data.data; + for (const action of data.actions) { + setSelectedPermissions((prev) => ({ + ...prev, + [action.actionId]: true + })); + } + } + + setLoadingPage(false); + } + + load(); + }, []); + + async function savePermissions() { + setLoadingSavePermissions(true); + + const actionsRes = await api + .post(`/api-key/${apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes && actionsRes.status === 200) { + toast({ + title: "Permissions updated", + description: "The permissions have been updated." + }); + } + + setLoadingSavePermissions(false); + } + + return ( + <> + {!loadingPage && ( + + + + + Permissions + + + Determine what this API key can do + + + + + + + + + + + + )} + + ); +} diff --git a/src/app/admin/api-keys/create/page.tsx b/src/app/admin/api-keys/create/page.tsx new file mode 100644 index 00000000..c76b1859 --- /dev/null +++ b/src/app/admin/api-keys/create/page.tsx @@ -0,0 +1,402 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { InfoIcon } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import Link from "next/link"; +import { + CreateOrgApiKeyBody, + CreateOrgApiKeyResponse +} from "@server/routers/apiKeys"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import moment from "moment"; +import CopyTextBox from "@app/components/CopyTextBox"; +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; + +const createFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters." + }) + .max(255, { + message: "Name must not be longer than 255 characters." + }) +}); + +type CreateFormValues = z.infer; + +const copiedFormSchema = z + .object({ + copied: z.boolean() + }) + .refine( + (data) => { + return data.copied; + }, + { + message: "You must confirm that you have copied the API key.", + path: ["copied"] + } + ); + +type CopiedFormValues = z.infer; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + + const [loadingPage, setLoadingPage] = useState(true); + const [createLoading, setCreateLoading] = useState(false); + const [apiKey, setApiKey] = useState(null); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + + const form = useForm({ + resolver: zodResolver(createFormSchema), + defaultValues: { + name: "" + } + }); + + const copiedForm = useForm({ + resolver: zodResolver(copiedFormSchema), + defaultValues: { + copied: false + } + }); + + async function onSubmit(data: CreateFormValues) { + setCreateLoading(true); + + let payload: CreateOrgApiKeyBody = { + name: data.name + }; + + const res = await api + .put>(`/api-key`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating API key", + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + + console.log({ + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }); + + const actionsRes = await api + .post(`/api-key/${data.apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes) { + setApiKey(data); + } + } + + setCreateLoading(false); + } + + async function onCopiedSubmit(data: CopiedFormValues) { + if (!data.copied) { + return; + } + + router.push(`/admin/api-keys`); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + {!apiKey && ( + <> + + + + API Key Information + + + + +
+ + ( + + + Name + + + + + + + )} + /> + + +
+
+
+ + + + + Permissions + + + Determine what this API key can do + + + + + + + + )} + + {apiKey && ( + + + + Your API Key + + + + + + + Name + + + + + + + + Created + + + {moment( + apiKey.createdAt + ).format("lll")} + + + + + + + + Save Your API Key + + + You will only be able to see this + once. Make sure to copy it to a + secure place. + + + +

+ Your API key is: +

+ + + +
+ + ( + +
+ { + copiedForm.setValue( + "copied", + e as boolean + ); + }} + /> + +
+ +
+ )} + /> + + +
+
+ )} +
+ +
+ {!apiKey && ( + + )} + {!apiKey && ( + + )} + + {apiKey && ( + + )} +
+
+ )} + + ); +} diff --git a/src/app/admin/api-keys/page.tsx b/src/app/admin/api-keys/page.tsx new file mode 100644 index 00000000..b4a00806 --- /dev/null +++ b/src/app/admin/api-keys/page.tsx @@ -0,0 +1,46 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListRootApiKeysResponse } from "@server/routers/apiKeys"; +import ApiKeysTable, { ApiKeyRow } from "./ApiKeysTable"; + +type ApiKeyPageProps = {}; + +export const dynamic = "force-dynamic"; + +export default async function ApiKeysPage(props: ApiKeyPageProps) { + let apiKeys: ListRootApiKeysResponse["apiKeys"] = []; + try { + const res = await internal.get>( + `/api-keys`, + await authCookieHeader() + ); + apiKeys = res.data.data.apiKeys; + } catch (e) {} + + const rows: ApiKeyRow[] = apiKeys.map((key) => { + return { + name: key.name, + id: key.apiKeyId, + key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`, + createdAt: key.createdAt + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx index ebda31a1..d244e13d 100644 --- a/src/app/admin/idp/[idpId]/layout.tsx +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -40,6 +40,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { { title: "General", href: `/admin/idp/${params.idpId}/general` + }, + { + title: "Organization Policies", + href: `/admin/idp/${params.idpId}/policies`, + showProfessional: true } ]; diff --git a/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx b/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx new file mode 100644 index 00000000..222e98eb --- /dev/null +++ b/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx @@ -0,0 +1,33 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + onAdd: () => void; +} + +export function PolicyDataTable({ + columns, + data, + onAdd +}: DataTableProps) { + return ( + + ); +} diff --git a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx new file mode 100644 index 00000000..df78c648 --- /dev/null +++ b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx @@ -0,0 +1,159 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@app/components/ui/button"; +import { + ArrowUpDown, + Trash2, + MoreHorizontal, + Pencil, + ArrowRight +} from "lucide-react"; +import { PolicyDataTable } from "./PolicyDataTable"; +import { Badge } from "@app/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import Link from "next/link"; +import { InfoPopup } from "@app/components/ui/info-popup"; + +export interface PolicyRow { + orgId: string; + roleMapping?: string; + orgMapping?: string; +} + +interface Props { + policies: PolicyRow[]; + onDelete: (orgId: string) => void; + onAdd: () => void; + onEdit: (policy: PolicyRow) => void; +} + +export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) { + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const r = row.original; + + return ( + + + + + + { + onDelete(r.orgId); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "orgId", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "roleMapping", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const mapping = row.original.roleMapping; + return mapping ? ( + 50 ? `${mapping.substring(0, 50)}...` : mapping} + info={mapping} + /> + ) : ( + "--" + ); + } + }, + { + accessorKey: "orgMapping", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const mapping = row.original.orgMapping; + return mapping ? ( + 50 ? `${mapping.substring(0, 50)}...` : mapping} + info={mapping} + /> + ) : ( + "--" + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const policy = row.original; + return ( +
+ +
+ ); + } + } + ]; + + return ; +} diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx new file mode 100644 index 00000000..9fb9b49b --- /dev/null +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -0,0 +1,645 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react"; +import PolicyTable, { PolicyRow } from "./PolicyTable"; +import { AxiosResponse } from "axios"; +import { ListOrgsResponse } from "@server/routers/org"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import Link from "next/link"; +import { Textarea } from "@app/components/ui/textarea"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { GetIdpResponse } from "@server/routers/idp"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionFooter, + SettingsSectionForm +} from "@app/components/Settings"; + +type Organization = { + orgId: string; + name: string; +}; + +const policyFormSchema = z.object({ + orgId: z.string().min(1, { message: "Organization is required" }), + roleMapping: z.string().optional(), + orgMapping: z.string().optional() +}); + +const defaultMappingsSchema = z.object({ + defaultRoleMapping: z.string().optional(), + defaultOrgMapping: z.string().optional() +}); + +type PolicyFormValues = z.infer; +type DefaultMappingsValues = z.infer; + +export default function PoliciesPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const { idpId } = useParams(); + + const [pageLoading, setPageLoading] = useState(true); + const [addPolicyLoading, setAddPolicyLoading] = useState(false); + const [editPolicyLoading, setEditPolicyLoading] = useState(false); + const [deletePolicyLoading, setDeletePolicyLoading] = useState(false); + const [updateDefaultMappingsLoading, setUpdateDefaultMappingsLoading] = + useState(false); + const [policies, setPolicies] = useState([]); + const [organizations, setOrganizations] = useState([]); + const [showAddDialog, setShowAddDialog] = useState(false); + const [editingPolicy, setEditingPolicy] = useState(null); + + const form = useForm({ + resolver: zodResolver(policyFormSchema), + defaultValues: { + orgId: "", + roleMapping: "", + orgMapping: "" + } + }); + + const defaultMappingsForm = useForm({ + resolver: zodResolver(defaultMappingsSchema), + defaultValues: { + defaultRoleMapping: "", + defaultOrgMapping: "" + } + }); + + const loadIdp = async () => { + try { + const res = await api.get>( + `/idp/${idpId}` + ); + if (res.status === 200) { + const data = res.data.data; + defaultMappingsForm.reset({ + defaultRoleMapping: data.idp.defaultRoleMapping || "", + defaultOrgMapping: data.idp.defaultOrgMapping || "" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + const loadPolicies = async () => { + try { + const res = await api.get(`/idp/${idpId}/org`); + if (res.status === 200) { + setPolicies(res.data.data.policies); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + const loadOrganizations = async () => { + try { + const res = await api.get>("/orgs"); + if (res.status === 200) { + const existingOrgIds = policies.map((p) => p.orgId); + const availableOrgs = res.data.data.orgs.filter( + (org) => !existingOrgIds.includes(org.orgId) + ); + setOrganizations(availableOrgs); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + useEffect(() => { + async function load() { + setPageLoading(true); + await loadPolicies(); + await loadIdp(); + setPageLoading(false); + } + load(); + }, [idpId]); + + const onAddPolicy = async (data: PolicyFormValues) => { + setAddPolicyLoading(true); + try { + const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, { + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + }); + if (res.status === 201) { + const newPolicy = { + orgId: data.orgId, + name: + organizations.find((org) => org.orgId === data.orgId) + ?.name || "", + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + }; + setPolicies([...policies, newPolicy]); + toast({ + title: "Success", + description: "Policy added successfully" + }); + setShowAddDialog(false); + form.reset(); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setAddPolicyLoading(false); + } + }; + + const onEditPolicy = async (data: PolicyFormValues) => { + if (!editingPolicy) return; + + setEditPolicyLoading(true); + try { + const res = await api.post( + `/idp/${idpId}/org/${editingPolicy.orgId}`, + { + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + } + ); + if (res.status === 200) { + setPolicies( + policies.map((policy) => + policy.orgId === editingPolicy.orgId + ? { + ...policy, + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + } + : policy + ) + ); + toast({ + title: "Success", + description: "Policy updated successfully" + }); + setShowAddDialog(false); + setEditingPolicy(null); + form.reset(); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setEditPolicyLoading(false); + } + }; + + const onDeletePolicy = async (orgId: string) => { + setDeletePolicyLoading(true); + try { + const res = await api.delete(`/idp/${idpId}/org/${orgId}`); + if (res.status === 200) { + setPolicies( + policies.filter((policy) => policy.orgId !== orgId) + ); + toast({ + title: "Success", + description: "Policy deleted successfully" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeletePolicyLoading(false); + } + }; + + const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => { + setUpdateDefaultMappingsLoading(true); + try { + const res = await api.post(`/idp/${idpId}/oidc`, { + defaultRoleMapping: data.defaultRoleMapping, + defaultOrgMapping: data.defaultOrgMapping + }); + if (res.status === 200) { + toast({ + title: "Success", + description: "Default mappings updated successfully" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setUpdateDefaultMappingsLoading(false); + } + }; + + if (pageLoading) { + return null; + } + + return ( + <> + + + + + About Organization Policies + + + Organization policies are used to control access to + organizations based on the user's ID token. You can + specify JMESPath expressions to extract role and + organization information from the ID token. For more + information, see{" "} + + the documentation + + + + + + + + + Default Mappings (Optional) + + + The default mappings are used when when there is not + an organization policy defined for an organization. + You can specify the default role and organization + mappings to fall back to here. + + + +
+ +
+ ( + + + Default Role Mapping + + + + + + JMESPath to extract role + information from the ID + token. The result of this + expression must return the + role name as defined in the + organization as a string. + + + + )} + /> + + ( + + + Default Organization Mapping + + + + + + JMESPath to extract + organization information + from the ID token. This + expression must return thr + org ID or true for the user + to be allowed to access the + organization. + + + + )} + /> +
+
+ + + + +
+
+ + { + loadOrganizations(); + form.reset({ + orgId: "", + roleMapping: "", + orgMapping: "" + }); + setEditingPolicy(null); + setShowAddDialog(true); + }} + onEdit={(policy) => { + setEditingPolicy(policy); + form.reset({ + orgId: policy.orgId, + roleMapping: policy.roleMapping || "", + orgMapping: policy.orgMapping || "" + }); + setShowAddDialog(true); + }} + /> +
+ + { + setShowAddDialog(val); + setEditingPolicy(null); + form.reset(); + }} + > + + + + {editingPolicy + ? "Edit Organization Policy" + : "Add Organization Policy"} + + + Configure access for an organization + + + +
+ + ( + + Organization + {editingPolicy ? ( + + ) : ( + + + + + + + + + + + + No org + found. + + + {organizations.map( + ( + org + ) => ( + { + form.setValue( + "orgId", + org.orgId + ); + }} + > + + { + org.name + } + + ) + )} + + + + + + )} + + + )} + /> + + ( + + + Role Mapping Path (Optional) + + + + + + JMESPath to extract role + information from the ID token. + The result of this expression + must return the role name as + defined in the organization as a + string. + + + + )} + /> + + ( + + + Organization Mapping Path + (Optional) + + + + + + JMESPath to extract organization + information from the ID token. + This expression must return the + org ID or true for the user to + be allowed to access the + organization. + + + + )} + /> + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/app/admin/license/LicenseKeysDataTable.tsx b/src/app/admin/license/LicenseKeysDataTable.tsx index dea3f38d..98ed814a 100644 --- a/src/app/admin/license/LicenseKeysDataTable.tsx +++ b/src/app/admin/license/LicenseKeysDataTable.tsx @@ -16,7 +16,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard"; type LicenseKeysDataTableProps = { licenseKeys: LicenseKeyCache[]; - onDelete: (key: string) => void; + onDelete: (key: LicenseKeyCache) => void; onCreate: () => void; }; @@ -124,7 +124,7 @@ export function LicenseKeysDataTable({
diff --git a/src/app/admin/license/components/SitePriceCalculator.tsx b/src/app/admin/license/components/SitePriceCalculator.tsx index cee00a44..f0ff460c 100644 --- a/src/app/admin/license/components/SitePriceCalculator.tsx +++ b/src/app/admin/license/components/SitePriceCalculator.tsx @@ -40,19 +40,25 @@ export function SitePriceCalculator({ setSiteCount((prev) => (prev > 1 ? prev - 1 : 1)); }; - const totalCost = mode === "license" - ? licenseFlatRate + (siteCount * pricePerSite) - : siteCount * pricePerSite; + const totalCost = + mode === "license" + ? licenseFlatRate + siteCount * pricePerSite + : siteCount * pricePerSite; return ( - {mode === "license" ? "Purchase License" : "Purchase Additional Sites"} + {mode === "license" + ? "Purchase License" + : "Purchase Additional Sites"} - Choose how many sites you want to {mode === "license" ? "purchase a license for" : "add to your existing license"}. + Choose how many sites you want to{" "} + {mode === "license" + ? "purchase a license for. You can always add more sites later." + : "add to your existing license."} @@ -108,14 +114,26 @@ export function SitePriceCalculator({ Number of sites: - - {siteCount} - + {siteCount}
Total: ${totalCost.toFixed(2)} / mo
+ +

+ For the most up-to-date pricing, please visit + our{" "} + + pricing page + + . +

diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index 153fe7a6..74f86c96 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -55,6 +55,7 @@ import { Progress } from "@app/components/ui/progress"; import { MinusCircle, PlusCircle } from "lucide-react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { SitePriceCalculator } from "./components/SitePriceCalculator"; +import Link from "next/link"; const formSchema = z.object({ licenseKey: z @@ -75,9 +76,8 @@ export default function LicensePage() { const [rows, setRows] = useState([]); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedLicenseKey, setSelectedLicenseKey] = useState( - null - ); + const [selectedLicenseKey, setSelectedLicenseKey] = + useState(null); const router = useRouter(); const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext(); const [hostLicense, setHostLicense] = useState(null); @@ -136,7 +136,8 @@ export default function LicensePage() { async function deleteLicenseKey(key: string) { try { setIsDeletingLicense(true); - const res = await api.delete(`/license/${key}`); + const encodedKey = encodeURIComponent(key); + const res = await api.delete(`/license/${encodedKey}`); if (res.data.data) { updateLicenseStatus(res.data.data); } @@ -294,7 +295,11 @@ export default function LicensePage() {

Are you sure you want to delete the license key{" "} - {obfuscateLicenseKey(selectedLicenseKey)} + + {obfuscateLicenseKey( + selectedLicenseKey.licenseKey + )} + ?

@@ -310,8 +315,10 @@ export default function LicensePage() {

} buttonText="Confirm Delete License Key" - onConfirm={async () => deleteLicenseKey(selectedLicenseKey)} - string={selectedLicenseKey} + onConfirm={async () => + deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted) + } + string={selectedLicenseKey.licenseKey} title="Delete License Key" /> )} @@ -428,12 +435,6 @@ export default function LicensePage() { {!licenseStatus?.isHostLicensed ? ( <> - ) : ( - + <> + + )} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index c8b98453..b05bf30b 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -86,7 +86,7 @@ export const adminNavItems: SidebarNavItem[] = [ }, { title: "API Keys", - href: "/{orgId}/settings/api-keys", + href: "/admin/api-keys", icon: , showProfessional: true }, diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index b354fe26..eb590eb0 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -35,7 +35,8 @@ export function HorizontalTabs({ .replace("{orgId}", params.orgId as string) .replace("{resourceId}", params.resourceId as string) .replace("{niceId}", params.niceId as string) - .replace("{userId}", params.userId as string); + .replace("{userId}", params.userId as string) + .replace("{apiKeyId}", params.apiKeyId as string); } return ( diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx new file mode 100644 index 00000000..18bb11d4 --- /dev/null +++ b/src/components/PermissionsSelectBox.tsx @@ -0,0 +1,238 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; + +type PermissionsSelectBoxProps = { + root?: boolean; + selectedPermissions: Record; + onChange: (updated: Record) => void; +}; + +function getActionsCategories(root: boolean) { + const actionsByCategory: Record> = { + Organization: { + "Get Organization": "getOrg", + "Update Organization": "updateOrg", + "Get Organization User": "getOrgUser", + "List Organization Domains": "listOrgDomains", + "Check Org ID": "checkOrgId", + "List Orgs": "listOrgs" + }, + + Site: { + "Create Site": "createSite", + "Delete Site": "deleteSite", + "Get Site": "getSite", + "List Sites": "listSites", + "Update Site": "updateSite", + "List Allowed Site Roles": "listSiteRoles" + }, + + Resource: { + "Create Resource": "createResource", + "Delete Resource": "deleteResource", + "Get Resource": "getResource", + "List Resources": "listResources", + "Update Resource": "updateResource", + "List Resource Users": "listResourceUsers", + "Set Resource Users": "setResourceUsers", + "Set Allowed Resource Roles": "setResourceRoles", + "List Allowed Resource Roles": "listResourceRoles", + "Set Resource Password": "setResourcePassword", + "Set Resource Pincode": "setResourcePincode", + "Set Resource Email Whitelist": "setResourceWhitelist", + "Get Resource Email Whitelist": "getResourceWhitelist" + }, + + Target: { + "Create Target": "createTarget", + "Delete Target": "deleteTarget", + "Get Target": "getTarget", + "List Targets": "listTargets", + "Update Target": "updateTarget" + }, + + Role: { + "Create Role": "createRole", + "Delete Role": "deleteRole", + "Get Role": "getRole", + "List Roles": "listRoles", + "Update Role": "updateRole", + "List Allowed Role Resources": "listRoleResources" + }, + + User: { + "Invite User": "inviteUser", + "Remove User": "removeUser", + "List Users": "listUsers", + "Add User Role": "addUserRole" + }, + + "Access Token": { + "Generate Access Token": "generateAccessToken", + "Delete Access Token": "deleteAcessToken", + "List Access Tokens": "listAccessTokens" + }, + + "Resource Rule": { + "Create Resource Rule": "createResourceRule", + "Delete Resource Rule": "deleteResourceRule", + "List Resource Rules": "listResourceRules", + "Update Resource Rule": "updateResourceRule" + } + + // "Newt": { + // "Create Newt": "createNewt" + // }, + }; + + if (root) { + actionsByCategory["Organization"] = { + "Create Organization": "createOrg", + "Delete Organization": "deleteOrg", + "List API Keys": "listApiKeys", + "List API Key Actions": "listApiKeyActions", + "Set API Key Allowed Actions": "setApiKeyActions", + "Create API Key": "createApiKey", + "Delete API Key": "deleteApiKey", + ...actionsByCategory["Organization"] + }; + + actionsByCategory["Identity Provider (IDP)"] = { + "Create IDP": "createIdp", + "Update IDP": "updateIdp", + "Delete IDP": "deleteIdp", + "List IDP": "listIdps", + "Get IDP": "getIdp", + "Create IDP Org Policy": "createIdpOrg", + "Delete IDP Org Policy": "deleteIdpOrg", + "List IDP Orgs": "listIdpOrgs", + "Update IDP Org": "updateIdpOrg" + }; + } + + return actionsByCategory; +} + +export default function PermissionsSelectBox({ + root, + selectedPermissions, + onChange +}: PermissionsSelectBoxProps) { + const actionsByCategory = getActionsCategories(root ?? false); + + const togglePermission = (key: string, checked: boolean) => { + onChange({ + ...selectedPermissions, + [key]: checked + }); + }; + + const areAllCheckedInCategory = (actions: Record) => { + return Object.values(actions).every( + (action) => selectedPermissions[action] + ); + }; + + const toggleAllInCategory = ( + actions: Record, + value: boolean + ) => { + const updated = { ...selectedPermissions }; + Object.values(actions).forEach((action) => { + updated[action] = value; + }); + onChange(updated); + }; + + const allActions = Object.values(actionsByCategory).flatMap(Object.values); + const allPermissionsChecked = allActions.every( + (action) => selectedPermissions[action] + ); + + const toggleAllPermissions = (checked: boolean) => { + const updated: Record = {}; + allActions.forEach((action) => { + updated[action] = checked; + }); + onChange(updated); + }; + + return ( + <> +
+ + toggleAllPermissions(checked as boolean) + } + /> +
+ + {Object.entries(actionsByCategory).map( + ([category, actions]) => { + const allChecked = areAllCheckedInCategory(actions); + return ( + + {category} + +
+ + toggleAllInCategory( + actions, + checked as boolean + ) + } + /> + {Object.entries(actions).map( + ([label, value]) => ( + + togglePermission( + value, + checked as boolean + ) + } + /> + ) + )} +
+
+
+ ); + } + )} +
+ + ); +} diff --git a/src/contexts/apiKeyContext.ts b/src/contexts/apiKeyContext.ts new file mode 100644 index 00000000..dd6c9b83 --- /dev/null +++ b/src/contexts/apiKeyContext.ts @@ -0,0 +1,16 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { GetApiKeyResponse } from "@server/routers/apiKeys"; +import { createContext } from "react"; + +interface ApiKeyContextType { + apiKey: GetApiKeyResponse; + updateApiKey: (updatedApiKey: Partial) => void; +} + +const ApiKeyContext = createContext(undefined); + +export default ApiKeyContext; diff --git a/src/hooks/useApikeyContext.ts b/src/hooks/useApikeyContext.ts new file mode 100644 index 00000000..3ebcbddc --- /dev/null +++ b/src/hooks/useApikeyContext.ts @@ -0,0 +1,17 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import ApiKeyContext from "@app/contexts/apiKeyContext"; +import { useContext } from "react"; + +export function useApiKeyContext() { + const context = useContext(ApiKeyContext); + if (context === undefined) { + throw new Error( + "useApiKeyContext must be used within a ApiKeyProvider" + ); + } + return context; +} diff --git a/src/providers/ApiKeyProvider.tsx b/src/providers/ApiKeyProvider.tsx new file mode 100644 index 00000000..13061da3 --- /dev/null +++ b/src/providers/ApiKeyProvider.tsx @@ -0,0 +1,42 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import ApiKeyContext from "@app/contexts/apiKeyContext"; +import { GetApiKeyResponse } from "@server/routers/apiKeys"; +import { useState } from "react"; + +interface ApiKeyProviderProps { + children: React.ReactNode; + apiKey: GetApiKeyResponse; +} + +export function ApiKeyProvider({ children, apiKey: ak }: ApiKeyProviderProps) { + const [apiKey, setApiKey] = useState(ak); + + const updateApiKey = (updatedApiKey: Partial) => { + if (!apiKey) { + throw new Error("No API key to update"); + } + setApiKey((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ...updatedApiKey + }; + }); + }; + + return ( + + {children} + + ); +} + +export default ApiKeyProvider;