mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 12:19:50 +00:00
Add logging for access for new public resources
This commit is contained in:
@@ -17,3 +17,4 @@ export * from "./queryAccessAuditLog";
|
|||||||
export * from "./exportAccessAuditLog";
|
export * from "./exportAccessAuditLog";
|
||||||
export * from "./queryConnectionAuditLog";
|
export * from "./queryConnectionAuditLog";
|
||||||
export * from "./exportConnectionAuditLog";
|
export * from "./exportConnectionAuditLog";
|
||||||
|
export * from "./logAccessAuditAttempt";
|
||||||
|
|||||||
95
server/private/routers/auditLogs/logAccessAuditAttempt.ts
Normal file
95
server/private/routers/auditLogs/logAccessAuditAttempt.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 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 { NextFunction } from "express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||||
|
|
||||||
|
export const logAccessAuditAttemptSchema = z.object({
|
||||||
|
resourceId: z.number().int().positive(),
|
||||||
|
action: z.boolean(),
|
||||||
|
type: z.enum(["login", "ssh", "vnc", "rdp"])
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logAccessAuditAttemptParams = z.object({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function logAccessAuditAttempt(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = logAccessAuditAttemptSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsedParams = logAccessAuditAttemptParams.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
const { resourceId, action, type } = parsedBody.data;
|
||||||
|
|
||||||
|
const username = req.user?.username;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
await logAccessAudit({
|
||||||
|
orgId: orgId,
|
||||||
|
resourceId: resourceId,
|
||||||
|
action: action,
|
||||||
|
...(username && userId
|
||||||
|
? {
|
||||||
|
user: {
|
||||||
|
username,
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
type: type,
|
||||||
|
userAgent: req.headers["user-agent"],
|
||||||
|
requestIp: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
return response<null>(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Access audit attempt logged successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { eq, gt, lt, and, count, desc, inArray, isNull } from "drizzle-orm";
|
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -120,7 +120,10 @@ function getWhere(data: Q) {
|
|||||||
lt(accessAuditLog.timestamp, data.timeEnd),
|
lt(accessAuditLog.timestamp, data.timeEnd),
|
||||||
eq(accessAuditLog.orgId, data.orgId),
|
eq(accessAuditLog.orgId, data.orgId),
|
||||||
data.resourceId
|
data.resourceId
|
||||||
? eq(accessAuditLog.resourceId, data.resourceId)
|
? or(
|
||||||
|
eq(accessAuditLog.resourceId, data.resourceId),
|
||||||
|
eq(accessAuditLog.siteResourceId, data.resourceId)
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.actor ? eq(accessAuditLog.actor, data.actor) : undefined,
|
data.actor ? eq(accessAuditLog.actor, data.actor) : undefined,
|
||||||
data.actorType
|
data.actorType
|
||||||
@@ -233,7 +236,6 @@ async function enrichWithResourceDetails(
|
|||||||
const details = siteResourceMap.get(log.siteResourceId);
|
const details = siteResourceMap.get(log.siteResourceId);
|
||||||
return {
|
return {
|
||||||
...log,
|
...log,
|
||||||
resourceId: log.siteResourceId,
|
|
||||||
resourceName: details?.name ?? null,
|
resourceName: details?.name ?? null,
|
||||||
resourceNiceId: details?.niceId ?? null
|
resourceNiceId: details?.niceId ?? null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -878,3 +878,9 @@ authenticated.post(
|
|||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
client.rebuildClientAssociationsCacheRoute
|
client.rebuildClientAssociationsCacheRoute
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/logs/access/attempt",
|
||||||
|
verifyOrgAccess,
|
||||||
|
logs.logAccessAuditAttempt
|
||||||
|
);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export type QueryAccessAuditLogResponse = {
|
|||||||
actorType: string | null;
|
actorType: string | null;
|
||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
|
siteResourceId: number | null;
|
||||||
resourceName: string | null;
|
resourceName: string | null;
|
||||||
resourceNiceId: string | null;
|
resourceNiceId: string | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export default function GeneralPage() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
row.original.type === "ssh"
|
row.original.siteResourceId != null
|
||||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||||
}
|
}
|
||||||
@@ -369,7 +369,9 @@ export default function GeneralPage() {
|
|||||||
value: "whitelistedEmail",
|
value: "whitelistedEmail",
|
||||||
label: "Whitelisted Email"
|
label: "Whitelisted Email"
|
||||||
},
|
},
|
||||||
{ value: "ssh", label: "SSH" }
|
{ value: "ssh", label: "SSH" },
|
||||||
|
{ value: "rdp", label: "RDP" },
|
||||||
|
{ value: "vnc", label: "VNC" }
|
||||||
]}
|
]}
|
||||||
label={t("type")}
|
label={t("type")}
|
||||||
selectedValue={filters.type}
|
selectedValue={filters.type}
|
||||||
@@ -384,8 +386,10 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const typeLabel =
|
const typeLabel =
|
||||||
row.original.type === "ssh"
|
row.original.type === "ssh" ||
|
||||||
? "SSH"
|
row.original.type === "rdp" ||
|
||||||
|
row.original.type === "vnc"
|
||||||
|
? row.original.type.toUpperCase()
|
||||||
: row.original.type.charAt(0).toUpperCase() +
|
: row.original.type.charAt(0).toUpperCase() +
|
||||||
row.original.type.slice(1);
|
row.original.type.slice(1);
|
||||||
return <span>{typeLabel || "-"}</span>;
|
return <span>{typeLabel || "-"}</span>;
|
||||||
@@ -513,7 +517,15 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
||||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||||
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
const types = [
|
||||||
|
"password",
|
||||||
|
"pincode",
|
||||||
|
"login",
|
||||||
|
"whitelistedEmail",
|
||||||
|
"ssh",
|
||||||
|
"rdp",
|
||||||
|
"vnc"
|
||||||
|
];
|
||||||
const actors = [
|
const actors = [
|
||||||
"alice@example.com",
|
"alice@example.com",
|
||||||
"bob@example.com",
|
"bob@example.com",
|
||||||
@@ -538,6 +550,7 @@ function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
|||||||
actor,
|
actor,
|
||||||
actorId: actor ? `user-${i}` : null,
|
actorId: actor ? `user-${i}` : null,
|
||||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||||
|
siteResourceId: null,
|
||||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
resourceName: `Resource ${(i % 3) + 1}`,
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
loadEncryptedLocalStorage,
|
loadEncryptedLocalStorage,
|
||||||
saveEncryptedLocalStorage
|
saveEncryptedLocalStorage
|
||||||
} from "@app/lib/secureLocalStorage";
|
} from "@app/lib/secureLocalStorage";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
declare module "react" {
|
declare module "react" {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
@@ -96,6 +98,7 @@ export default function RdpClient({
|
|||||||
primaryColor?: string | null;
|
primaryColor?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
const STORAGE_KEY = "pangolin_rdp_credentials";
|
const STORAGE_KEY = "pangolin_rdp_credentials";
|
||||||
const resourceName = target?.name?.trim() || null;
|
const resourceName = target?.name?.trim() || null;
|
||||||
|
|
||||||
@@ -311,6 +314,11 @@ export default function RdpClient({
|
|||||||
values,
|
values,
|
||||||
target.authToken
|
target.authToken
|
||||||
);
|
);
|
||||||
|
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||||
|
resourceId: target.resourceId,
|
||||||
|
action: true,
|
||||||
|
type: "rdp"
|
||||||
|
});
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
userInteraction.setVisibility(true);
|
userInteraction.setVisibility(true);
|
||||||
@@ -320,6 +328,11 @@ export default function RdpClient({
|
|||||||
fileTransferRef.current = null;
|
fileTransferRef.current = null;
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||||
|
resourceId: target.resourceId,
|
||||||
|
action: false,
|
||||||
|
type: "rdp"
|
||||||
|
});
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
if (isIronError(err)) {
|
if (isIronError(err)) {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
loadEncryptedLocalStorage,
|
loadEncryptedLocalStorage,
|
||||||
saveEncryptedLocalStorage
|
saveEncryptedLocalStorage
|
||||||
} from "@app/lib/secureLocalStorage";
|
} from "@app/lib/secureLocalStorage";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
type AuthTab = "password" | "privateKey";
|
type AuthTab = "password" | "privateKey";
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@ export default function SshClient({
|
|||||||
}) {
|
}) {
|
||||||
const STORAGE_KEY = "pangolin_ssh_credentials";
|
const STORAGE_KEY = "pangolin_ssh_credentials";
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
const resourceName = target?.name?.trim() || null;
|
const resourceName = target?.name?.trim() || null;
|
||||||
|
|
||||||
const passwordTabSchema = z.object({
|
const passwordTabSchema = z.object({
|
||||||
@@ -263,6 +266,17 @@ export default function SshClient({
|
|||||||
let authConfirmed = false;
|
let authConfirmed = false;
|
||||||
let authErrorShown = false;
|
let authErrorShown = false;
|
||||||
let socketOpened = false;
|
let socketOpened = false;
|
||||||
|
let auditLogged = false;
|
||||||
|
|
||||||
|
const logAudit = (action: boolean) => {
|
||||||
|
if (auditLogged || !target) return;
|
||||||
|
auditLogged = true;
|
||||||
|
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||||
|
resourceId: target.resourceId,
|
||||||
|
action,
|
||||||
|
type: "ssh"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
socketOpened = true;
|
socketOpened = true;
|
||||||
@@ -294,6 +308,7 @@ export default function SshClient({
|
|||||||
if (msg.type === "data" && msg.data) {
|
if (msg.type === "data" && msg.data) {
|
||||||
if (!authConfirmed) {
|
if (!authConfirmed) {
|
||||||
authConfirmed = true;
|
authConfirmed = true;
|
||||||
|
logAudit(true);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
}
|
}
|
||||||
@@ -301,6 +316,7 @@ export default function SshClient({
|
|||||||
} else if (msg.type === "error") {
|
} else if (msg.type === "error") {
|
||||||
if (!authConfirmed) {
|
if (!authConfirmed) {
|
||||||
authErrorShown = true;
|
authErrorShown = true;
|
||||||
|
logAudit(false);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnectError(
|
setConnectError(
|
||||||
msg.error ?? t("sshErrorAuthFailed")
|
msg.error ?? t("sshErrorAuthFailed")
|
||||||
@@ -323,6 +339,7 @@ export default function SshClient({
|
|||||||
evt.data.text().then((text) => {
|
evt.data.text().then((text) => {
|
||||||
if (!authConfirmed) {
|
if (!authConfirmed) {
|
||||||
authConfirmed = true;
|
authConfirmed = true;
|
||||||
|
logAudit(true);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
}
|
}
|
||||||
@@ -332,6 +349,7 @@ export default function SshClient({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
|
logAudit(false);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
setConnectError(t("sshErrorWebSocket"));
|
setConnectError(t("sshErrorWebSocket"));
|
||||||
@@ -355,6 +373,7 @@ export default function SshClient({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!authConfirmed && !authErrorShown) {
|
if (!authConfirmed && !authErrorShown) {
|
||||||
|
logAudit(false);
|
||||||
setConnectError(t("sshErrorConnectionClosed"));
|
setConnectError(t("sshErrorConnectionClosed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
loadEncryptedLocalStorage,
|
loadEncryptedLocalStorage,
|
||||||
saveEncryptedLocalStorage
|
saveEncryptedLocalStorage
|
||||||
} from "@app/lib/secureLocalStorage";
|
} from "@app/lib/secureLocalStorage";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
type VncCredentialsForm = {
|
type VncCredentialsForm = {
|
||||||
password: string;
|
password: string;
|
||||||
@@ -52,6 +54,7 @@ export default function VncClient({
|
|||||||
primaryColor?: string | null;
|
primaryColor?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
const STORAGE_KEY = "pangolin_vnc_credentials";
|
const STORAGE_KEY = "pangolin_vnc_credentials";
|
||||||
const resourceName = target?.name?.trim() || null;
|
const resourceName = target?.name?.trim() || null;
|
||||||
|
|
||||||
@@ -179,6 +182,7 @@ export default function VncClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let authConfirmed = false;
|
let authConfirmed = false;
|
||||||
|
let auditLogged = false;
|
||||||
|
|
||||||
rfb.scaleViewport = true;
|
rfb.scaleViewport = true;
|
||||||
rfb.resizeSession = true;
|
rfb.resizeSession = true;
|
||||||
@@ -190,6 +194,12 @@ export default function VncClient({
|
|||||||
target.authToken
|
target.authToken
|
||||||
);
|
);
|
||||||
authConfirmed = true;
|
authConfirmed = true;
|
||||||
|
auditLogged = true;
|
||||||
|
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||||
|
resourceId: target.resourceId,
|
||||||
|
action: true,
|
||||||
|
type: "vnc"
|
||||||
|
});
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
});
|
});
|
||||||
@@ -201,6 +211,17 @@ export default function VncClient({
|
|||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
if (!authConfirmed && !e.detail.clean) {
|
if (!authConfirmed && !e.detail.clean) {
|
||||||
|
if (!auditLogged) {
|
||||||
|
auditLogged = true;
|
||||||
|
void api.post(
|
||||||
|
`/org/${target.orgId}/logs/access/attempt`,
|
||||||
|
{
|
||||||
|
resourceId: target.resourceId,
|
||||||
|
action: false,
|
||||||
|
type: "vnc"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
setConnectError(t("sshErrorConnectionClosed"));
|
setConnectError(t("sshErrorConnectionClosed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +230,12 @@ export default function VncClient({
|
|||||||
rfb.addEventListener(
|
rfb.addEventListener(
|
||||||
"securityfailure",
|
"securityfailure",
|
||||||
(e: { detail: { status: number; reason?: string } }) => {
|
(e: { detail: { status: number; reason?: string } }) => {
|
||||||
|
auditLogged = true;
|
||||||
|
void api.post(`/org/${target.orgId}/logs/access/attempt`, {
|
||||||
|
resourceId: target.resourceId,
|
||||||
|
action: false,
|
||||||
|
type: "vnc"
|
||||||
|
});
|
||||||
disconnect();
|
disconnect();
|
||||||
setConnectError(
|
setConnectError(
|
||||||
e.detail.reason ??
|
e.detail.reason ??
|
||||||
|
|||||||
Reference in New Issue
Block a user