diff --git a/server/auth/actions.ts b/server/auth/actions.ts index fc5daa4f8..213dab9d3 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -140,7 +140,11 @@ export enum ActionsEnum { exportLogs = "exportLogs", listApprovals = "listApprovals", updateApprovals = "updateApprovals", - signSshKey = "signSshKey" + signSshKey = "signSshKey", + createEventStreamingDestination = "createEventStreamingDestination", + updateEventStreamingDestination = "updateEventStreamingDestination", + deleteEventStreamingDestination = "deleteEventStreamingDestination", + listEventStreamingDestinations = "listEventStreamingDestinations" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 9d5955d51..1b031636f 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -417,6 +417,25 @@ export const siteProvisioningKeyOrg = pgTable( ] ); +export const eventStreamingDestinations = pgTable( + "eventStreamingDestinations", + { + destinationId: serial("destinationId").primaryKey(), + orgId: varchar("orgId", { length: 255 }) + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false), + sendRequestLogs: boolean("sendRequestLogs").notNull().default(false), + sendActionLogs: boolean("sendActionLogs").notNull().default(false), + sendAccessLogs: boolean("sendAccessLogs").notNull().default(false), + type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc. + config: text("config").notNull(), // JSON string with the configuration for the destination + enabled: boolean("enabled").notNull().default(true), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() + } +); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -439,3 +458,15 @@ export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; export type AccessAuditLog = InferSelectModel; export type ConnectionAuditLog = InferSelectModel; +export type SessionTransferToken = InferSelectModel< + typeof sessionTransferToken +>; +export type BannedEmail = InferSelectModel; +export type BannedIp = InferSelectModel; +export type SiteProvisioningKey = InferSelectModel; +export type SiteProvisioningKeyOrg = InferSelectModel< + typeof siteProvisioningKeyOrg +>; +export type EventStreamingDestination = InferSelectModel< + typeof eventStreamingDestinations +>; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 809c0c45d..9bb994266 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -7,7 +7,16 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { clients, domains, exitNodes, orgs, sessions, siteResources, sites, users } from "./schema"; +import { + clients, + domains, + exitNodes, + orgs, + sessions, + siteResources, + sites, + users +} from "./schema"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -401,6 +410,29 @@ export const siteProvisioningKeyOrg = sqliteTable( ] ); +export const eventStreamingDestinations = sqliteTable( + "eventStreamingDestinations", + { + destinationId: integer("destinationId").primaryKey({ + autoIncrement: true + }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false), + sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false), + sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false), + sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false), + type: text("type").notNull(), // e.g. "http", "kafka", etc. + config: text("config").notNull(), // JSON string with the configuration for the destination + enabled: integer("enabled", { mode: "boolean" }) + .notNull() + .default(true), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() + } +); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -423,3 +455,9 @@ export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; export type AccessAuditLog = InferSelectModel; export type ConnectionAuditLog = InferSelectModel; +export type BannedEmail = InferSelectModel; +export type BannedIp = InferSelectModel; +export type SiteProvisioningKey = InferSelectModel; +export type EventStreamingDestination = InferSelectModel< + typeof eventStreamingDestinations +>; diff --git a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts new file mode 100644 index 000000000..1c9de788a --- /dev/null +++ b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts @@ -0,0 +1,124 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { eventStreamingDestinations } 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"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.strictObject({ + type: z.string().nonempty(), + config: z.string().nonempty(), + enabled: z.boolean().optional().default(true), + sendConnectionLogs: z.boolean().optional().default(false), + sendRequestLogs: z.boolean().optional().default(false), + sendActionLogs: z.boolean().optional().default(false), + sendAccessLogs: z.boolean().optional().default(false) +}); + +export type CreateEventStreamingDestinationResponse = { + destinationId: number; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/event-streaming-destination", + description: "Create an event streaming destination for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createEventStreamingDestination( + 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 { orgId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { type, config, enabled } = parsedBody.data; + + const now = Date.now(); + + const [destination] = await db + .insert(eventStreamingDestinations) + .values({ + orgId, + type, + config, + enabled, + createdAt: now, + updatedAt: now, + sendAccessLogs: parsedBody.data.sendAccessLogs, + sendActionLogs: parsedBody.data.sendActionLogs, + sendConnectionLogs: parsedBody.data.sendConnectionLogs, + sendRequestLogs: parsedBody.data.sendRequestLogs + }) + .returning(); + + return response(res, { + data: { + destinationId: destination.destinationId + }, + success: true, + error: false, + message: "Event streaming destination created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts new file mode 100644 index 000000000..d93bc4405 --- /dev/null +++ b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts @@ -0,0 +1,103 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { eventStreamingDestinations } 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 { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + destinationId: z.coerce.number() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/event-streaming-destination/{destinationId}", + description: "Delete an event streaming destination for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteEventStreamingDestination( + 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 { orgId, destinationId } = parsedParams.data; + + const [existing] = await db + .select() + .from(eventStreamingDestinations) + .where( + and( + eq(eventStreamingDestinations.destinationId, destinationId), + eq(eventStreamingDestinations.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Event streaming destination not found" + ) + ); + } + + await db + .delete(eventStreamingDestinations) + .where( + and( + eq(eventStreamingDestinations.destinationId, destinationId), + eq(eventStreamingDestinations.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Event streaming destination deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/eventStreamingDestination/index.ts b/server/private/routers/eventStreamingDestination/index.ts new file mode 100644 index 000000000..595e9595b --- /dev/null +++ b/server/private/routers/eventStreamingDestination/index.ts @@ -0,0 +1,17 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createEventStreamingDestination"; +export * from "./updateEventStreamingDestination"; +export * from "./deleteEventStreamingDestination"; +export * from "./listEventStreamingDestinations"; \ No newline at end of file diff --git a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts new file mode 100644 index 000000000..b3f5ff149 --- /dev/null +++ b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts @@ -0,0 +1,144 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { eventStreamingDestinations } 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, sql } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +export type ListEventStreamingDestinationsResponse = { + destinations: { + destinationId: number; + orgId: string; + type: string; + config: string; + enabled: boolean; + createdAt: number; + updatedAt: number; + sendConnectionLogs: boolean; + sendRequestLogs: boolean; + sendActionLogs: boolean; + sendAccessLogs: boolean; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +async function query(orgId: string, limit: number, offset: number) { + const res = await db + .select() + .from(eventStreamingDestinations) + .where(eq(eventStreamingDestinations.orgId, orgId)) + .orderBy(sql`${eventStreamingDestinations.createdAt} DESC`) + .limit(limit) + .offset(offset); + return res; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/event-streaming-destination", + description: "List all event streaming destinations for a specific organization.", + tags: [OpenAPITags.Org], + request: { + query: querySchema, + params: paramsSchema + }, + responses: {} +}); + +export async function listEventStreamingDestinations( + 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 { orgId } = 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(orgId, limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(eventStreamingDestinations) + .where(eq(eventStreamingDestinations.orgId, orgId)); + + return response(res, { + data: { + destinations: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Event streaming destinations retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts new file mode 100644 index 000000000..1ad8f0081 --- /dev/null +++ b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts @@ -0,0 +1,141 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { eventStreamingDestinations } 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 { and, eq } from "drizzle-orm"; +import { parse } from "zod/v4/core"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + destinationId: z.coerce.number() + }) + .strict(); + +const bodySchema = z.strictObject({ + type: z.string().optional(), + config: z.string().optional(), + enabled: z.boolean().optional(), + sendConnectionLogs: z.boolean().optional().default(false), + sendRequestLogs: z.boolean().optional().default(false), + sendActionLogs: z.boolean().optional().default(false), + sendAccessLogs: z.boolean().optional().default(false) +}); + +export type UpdateEventStreamingDestinationResponse = { + destinationId: number; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/event-streaming-destination/{destinationId}", + description: "Update an event streaming destination for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateEventStreamingDestination( + 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 { orgId, destinationId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(eventStreamingDestinations) + .where( + and( + eq(eventStreamingDestinations.destinationId, destinationId), + eq(eventStreamingDestinations.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Event streaming destination not found" + ) + ); + } + + const updateData = parsedBody.data; + + await db + .update(eventStreamingDestinations) + .set(updateData) + .where( + and( + eq(eventStreamingDestinations.destinationId, destinationId), + eq(eventStreamingDestinations.orgId, orgId) + ) + ); + + + return response(res, { + data: { + destinationId + }, + success: true, + error: false, + message: "Event streaming destination updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 412895a41..41a4919a0 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -28,6 +28,7 @@ import * as approval from "#private/routers/approvals"; import * as ssh from "#private/routers/ssh"; import * as user from "#private/routers/user"; import * as siteProvisioning from "#private/routers/siteProvisioning"; +import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import { verifyOrgAccess, @@ -615,3 +616,40 @@ authenticated.patch( logActionAudit(ActionsEnum.updateSiteProvisioningKey), siteProvisioning.updateSiteProvisioningKey ); + +authenticated.put( + "/org/:orgId/event-streaming-destination", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createEventStreamingDestination), + logActionAudit(ActionsEnum.createEventStreamingDestination), + eventStreamingDestination.createEventStreamingDestination +); + +authenticated.post( + "/org/:orgId/event-streaming-destination/:destinationId", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.updateEventStreamingDestination), + logActionAudit(ActionsEnum.updateEventStreamingDestination), + eventStreamingDestination.updateEventStreamingDestination +); + +authenticated.delete( + "/org/:orgId/event-streaming-destination/:destinationId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteEventStreamingDestination), + logActionAudit(ActionsEnum.deleteEventStreamingDestination), + eventStreamingDestination.deleteEventStreamingDestination +); + +authenticated.get( + "/org/:orgId/event-streaming-destinations", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listEventStreamingDestinations), + eventStreamingDestination.listEventStreamingDestinations +);