Compare commits

...

23 Commits

Author SHA1 Message Date
Owen e0937a3afa Add validation for health check hostname
Fixes #3677
2026-09-02 10:42:59 -04:00
Owen 8d7e73afa8 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-09-02 10:32:24 -04:00
Owen a5ce56ea89 Move the session logs to private where it should be 2026-09-01 17:56:36 -04:00
miloschwartz 2f013335f9 make user lookup deterministic in blueprints by sorting 2026-09-01 15:36:39 -04:00
miloschwartz b4f6ae74d7 show warning when changing idp identifier mapping 2026-09-01 11:38:53 -04:00
Owen f1711ee0b0 Make it more clear that its a prefer 2026-09-01 10:09:16 -04:00
Owen 780d767a65 Merge branch 'main' into dev 2026-08-31 17:07:56 -04:00
Owen Schwartz c36cf698c1 Merge pull request #3627 from fosrl/feat/ip-filtering
feat: Add IP column filtering to tables
2026-08-31 16:59:34 -04:00
Owen Schwartz 22db0319c2 Merge pull request #3654 from iMord0/crowdsec-v1.7.1
Crowdsec v1.7.1
2026-08-31 16:50:59 -04:00
miloschwartz 39722d30af resolve security-key login only for a unique internal user 2026-08-31 14:39:36 -04:00
miloschwartz 8aef14cf9f harden initial server-admin setup against duplicate users and races 2026-08-31 12:36:42 -04:00
iMord0 7f94d99455 Update CrowdSec plugin version
Updated CrowdSec plugin version from v1.4.4 to v1.7.1
2026-08-28 20:51:51 +02:00
iMord0 1f9e99219d Updated CrowdSec plugin version
Updated CrowdSec plugin version from v1.4.4 to v1.7.8
2026-08-28 20:42:26 +02:00
Fred KISSIE d00b9478a2 Merge branch 'dev' into feat/ip-filtering 2026-08-24 22:06:55 +02:00
Fred KISSIE 4ddf36ebcc 💄 some last UI fixes 2026-08-24 20:39:30 +02:00
Fred KISSIE 28b32fe6f7 🏷️ fix types 2026-08-21 23:26:18 +02:00
Fred KISSIE adfb6003d9 Merge branch 'dev' into feat/ip-filtering 2026-08-21 23:24:09 +02:00
Fred KISSIE 6a5ecab013 Implement IP filtering for admin access logs 2026-08-21 22:49:45 +02:00
Fred KISSIE 2c197fab9f IP filtering on request log table finished 2026-08-21 21:04:40 +02:00
Fred KISSIE 65e4fe91b9 🚧 wip: add ip is column filter 2026-08-20 23:59:31 +02:00
Fred KISSIE 52c078a489 💄 ui 2026-08-18 23:28:31 +02:00
Fred KISSIE 195f67c6eb 💄 QoL for location column 2026-08-18 21:08:16 +02:00
Fred KISSIE 668a04bcd2 🚧 wip: IP column filtering 2026-08-14 21:52:16 +02:00
27 changed files with 792 additions and 440 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ experimental:
version: "{{.BadgerVersion}}"
crowdsec: # CrowdSec plugin configuration added
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
version: "v1.4.4"
version: "v1.7.1"
log:
level: "INFO"
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Learn more about JMESPath",
"idpJmespathLabel": "Identifier Path",
"idpJmespathLabelDescription": "The path to the user identifier in the ID token",
"idpIdentifierChangeTitle": "Identifier Path Change Warning",
"idpIdentifierChangeDescription": "You are about to change the identifier path. This will affect how existing users are mapped. Users who previously signed in through this identity provider may no longer be recognized as the same users.",
"idpIdentifierChangeConfirmMessage": "I confirm",
"idpIdentifierChangeWarningText": "This will affect how existing users are mapped",
"idpJmespathEmailPathOptional": "Email Path (Optional)",
"idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token",
"idpJmespathNamePathOptional": "Name Path (Optional)",
@@ -1573,6 +1577,8 @@
"search": "Search…",
"searchPlaceholder": "Search...",
"emptySearchOptions": "No options found",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Create",
"orgs": "Organizations",
"loginError": "An unexpected error occurred. Please try again.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Type:",
"createDomainName": "Name:",
"createDomainValue": "Value:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME Records",
"createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Method",
"healthSelectScheme": "Select Method",
"healthCheckPortInvalid": "Port must be between 1 and 65535",
"healthCheckHostnameInvalid": "Hostname must not contain whitespace",
"healthCheckPath": "Path",
"healthHostname": "IP / Host",
"healthPort": "Port",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets",
"remoteExitNodeNetworkingLabelsTitle": "Preference Labels",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Select labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...",
"remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels",
+85 -1
View File
@@ -26,7 +26,9 @@ import {
sites,
clients,
sessions,
labels
labels,
aiProviders,
virtualApiKeys
} from "./schema";
export const dnsChallenge = pgTable("dnsChallenges", {
@@ -614,6 +616,87 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = pgTable(
"aiSessionLog",
{
id: serial("id").primaryKey(),
sessionId: varchar("sessionId").notNull(),
orgId: varchar("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -660,3 +743,4 @@ export type AlertEmailRecipients = InferSelectModel<
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
+1 -81
View File
@@ -1,3 +1,4 @@
import { aiSessionLog } from "@server/db/sqlite";
import { randomUUID } from "crypto";
import { InferSelectModel, sql } from "drizzle-orm";
import {
@@ -1958,87 +1959,6 @@ export const aiBudgetBreachEvents = pgTable(
]
);
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = pgTable(
"aiSessionLog",
{
id: serial("id").primaryKey(),
sessionId: varchar("sessionId").notNull(),
orgId: varchar("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export const certificates = pgTable("certificates", {
certId: serial("certId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull().unique(),
+89 -1
View File
@@ -9,6 +9,7 @@ import {
uniqueIndex
} from "drizzle-orm/sqlite-core";
import {
aiProviders,
clients,
domains,
exitNodes,
@@ -20,7 +21,8 @@ import {
siteResources,
sites,
targetHealthCheck,
users
users,
virtualApiKeys
} from "./schema";
export const dnsChallenge = sqliteTable("dnsChallenges", {
@@ -609,6 +611,91 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull()
});
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = sqliteTable(
"aiSessionLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: text("sessionId").notNull(),
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
.default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -647,3 +734,4 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
+34 -118
View File
@@ -147,9 +147,7 @@ export const sites = sqliteTable(
.$type<"pending" | "approved">()
.default("approved")
},
(table) => [
index("idx_sites_orgId").on(table.orgId)
]
(table) => [index("idx_sites_orgId").on(table.orgId)]
);
export const resources = sqliteTable(
@@ -192,7 +190,9 @@ export const resources = sqliteTable(
mode: "boolean"
}),
applyRules: integer("applyRules", { mode: "boolean" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
stickySession: integer("stickySession", { mode: "boolean" })
.notNull()
.default(false),
@@ -220,10 +220,14 @@ export const resources = sqliteTable(
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
wildcard: integer("wildcard", { mode: "boolean" })
.notNull()
.default(false),
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.$type<
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
@@ -236,9 +240,7 @@ export const resources = sqliteTable(
.$type<"pending" | "approved">()
.default("approved")
},
(table) => [
index("idx_resources_orgId").on(table.orgId)
]
(table) => [index("idx_resources_orgId").on(table.orgId)]
);
export const resourceAiProviders = sqliteTable(
@@ -288,9 +290,7 @@ export const labels = sqliteTable(
})
.notNull()
},
(table) => [
index("idx_labels_orgId").on(table.orgId)
]
(table) => [index("idx_labels_orgId").on(table.orgId)]
);
export const launcherViews = sqliteTable("launcherViews", {
@@ -409,7 +409,9 @@ export const targets = sqliteTable(
method: text("method"),
port: integer("port").notNull(),
internalPort: integer("internalPort"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
@@ -705,9 +707,7 @@ export const newts = sqliteTable(
onDelete: "cascade"
})
},
(table) => [
index("idx_newts_siteId").on(table.siteId)
]
(table) => [index("idx_newts_siteId").on(table.siteId)]
);
export const clients = sqliteTable(
@@ -740,8 +740,12 @@ export const clients = sqliteTable(
online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
archived: integer("archived", { mode: "boolean" })
.notNull()
.default(false),
blocked: integer("blocked", { mode: "boolean" })
.notNull()
.default(false),
approvalState: text("approvalState").$type<
"pending" | "approved" | "denied"
>()
@@ -795,11 +799,11 @@ export const olms = sqliteTable(
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
archived: integer("archived", { mode: "boolean" })
.notNull()
.default(false)
},
(table) => [
index("idx_olms_userId").on(table.userId)
]
(table) => [index("idx_olms_userId").on(table.userId)]
);
export const currentFingerprint = sqliteTable("currentFingerprint", {
@@ -975,9 +979,7 @@ export const sessions = sqliteTable(
.notNull()
.default(false)
},
(table) => [
index("idx_sessions_userId").on(table.userId)
]
(table) => [index("idx_sessions_userId").on(table.userId)]
);
export const newtSessions = sqliteTable("newtSession", {
@@ -1007,7 +1009,9 @@ export const userOrgs = sqliteTable(
onDelete: "cascade"
})
.notNull(),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
isOwner: integer("isOwner", { mode: "boolean" })
.notNull()
.default(false),
autoProvisioned: integer("autoProvisioned", {
mode: "boolean"
}).default(false),
@@ -1062,14 +1066,12 @@ export const roles = sqliteTable(
}).default(false),
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
true
),
sshCreateHomeDir: integer("sshCreateHomeDir", {
mode: "boolean"
}).default(true),
sshUnixGroups: text("sshUnixGroups").default("[]")
},
(table) => [
index("idx_roles_orgId").on(table.orgId)
]
(table) => [index("idx_roles_orgId").on(table.orgId)]
);
export const userOrgRoles = sqliteTable(
@@ -1997,91 +1999,6 @@ export const aiBudgetBreachEvents = sqliteTable(
]
);
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = sqliteTable(
"aiSessionLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: text("sessionId").notNull(),
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
.default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
domain: text("domain").notNull().unique(),
@@ -2192,7 +2109,6 @@ export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders
+31 -19
View File
@@ -121,25 +121,37 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId
);
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
// The DB writes for all resources have already committed
// by this point, so a push failure for one target (e.g.
// a newt rejecting a malformed health check) must not
// abort pushing the rest, and must not mark the whole
// blueprint as failed when the config was actually
// persisted successfully.
try {
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
} catch (e) {
logger.error(
`Failed to push target ${target.targetId} to newt on site ${site.sites.siteId}. Error: ${e}`
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { and, asc, eq, or } from "drizzle-orm";
import { Transaction, User, userOrgs, users } from "@server/db";
export async function findOrgUserByIdentifier(
trx: Transaction,
orgId: string,
identifier: string
): Promise<User | null> {
const [match] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, identifier), eq(users.email, identifier)),
eq(userOrgs.orgId, orgId)
)
)
.orderBy(asc(users.dateCreated), asc(users.userId))
.limit(1);
return match?.user ?? null;
}
export async function resolveOrgUserIds(
trx: Transaction,
orgId: string,
identifiers: string[]
): Promise<string[]> {
const userIds = new Set<string>();
for (const identifier of identifiers) {
const user = await findOrgUserByIdentifier(trx, orgId, identifier);
if (user) {
userIds.add(user.userId);
}
}
return [...userIds];
}
+32 -45
View File
@@ -11,15 +11,14 @@ import {
siteNetworks,
siteResources,
Transaction,
userOrgs,
users,
userSiteResources,
networks
} from "@server/db";
import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { eq, and, ne, inArray, isNotNull } from "drizzle-orm";
import { Config } from "./types";
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
import { resolveOrgUserIds } from "./findOrgUser";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip";
@@ -389,28 +388,22 @@ export async function updatePrivateResources(
.where(eq(userSiteResources.siteResourceId, siteResourceId));
if (resourceData.users.length > 0) {
// get userIds from username
const usersToUpdate = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(
inArray(users.username, resourceData.users),
inArray(users.email, resourceData.users)
),
eq(userOrgs.orgId, orgId)
)
);
const userIds = await resolveOrgUserIds(
trx,
orgId,
resourceData.users
);
const userIds = usersToUpdate.map((user) => user.user.userId);
await trx
.insert(userSiteResources)
.values(
userIds.map((userId) => ({ userId, siteResourceId }))
);
if (userIds.length > 0) {
await trx
.insert(userSiteResources)
.values(
userIds.map((userId) => ({
userId,
siteResourceId
}))
);
}
}
// Get all admin role IDs for this org to exclude from deletion
@@ -721,28 +714,22 @@ export async function updatePrivateResources(
}
if (resourceData.users.length > 0) {
// get userIds from username
const usersToUpdate = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(
inArray(users.username, resourceData.users),
inArray(users.email, resourceData.users)
),
eq(userOrgs.orgId, orgId)
)
);
const userIds = await resolveOrgUserIds(
trx,
orgId,
resourceData.users
);
const userIds = usersToUpdate.map((user) => user.user.userId);
await trx
.insert(userSiteResources)
.values(
userIds.map((userId) => ({ userId, siteResourceId }))
);
if (userIds.length > 0) {
await trx
.insert(userSiteResources)
.values(
userIds.map((userId) => ({
userId,
siteResourceId
}))
);
}
}
if (resourceData.machines.length > 0) {
+8 -27
View File
@@ -46,11 +46,12 @@ import { encrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUserByIdentifier } from "./findOrgUser";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders";
@@ -1563,29 +1564,19 @@ async function syncUserResources(
.where(eq(userResources.resourceId, resourceId));
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
const user = await findOrgUserByIdentifier(trx, orgId, username);
if (!user) {
throw new Error(`User not found: ${username} in org ${orgId}`);
}
const existingUserResource = existingUserResources.find(
(rr) => rr.userId === user.user.userId
(rr) => rr.userId === user.userId
);
if (!existingUserResource) {
await trx.insert(userResources).values({
userId: user.user.userId,
userId: user.userId,
resourceId: resourceId
});
}
@@ -1955,29 +1946,19 @@ async function syncUserPolicies(
.where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
const user = await findOrgUserByIdentifier(trx, orgId, username);
if (!user) {
throw new Error(`User not found: ${username} in org ${orgId}`);
}
const existingUserPolicy = existingUserPoliciesList.find(
(up) => up.userId === user.user.userId
(up) => up.userId === user.userId
);
if (!existingUserPolicy) {
await trx.insert(userPolicies).values({
userId: user.user.userId,
userId: user.userId,
resourcePolicyId: policyId
});
}
+7 -26
View File
@@ -13,7 +13,7 @@ import {
userPolicies,
users
} from "@server/db";
import { eq, and, or } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { Config, ResourcePolicyData } from "./types";
import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
@@ -22,6 +22,7 @@ import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { findOrgUserByIdentifier } from "./findOrgUser";
export type ResourcePoliciesResults = {
resourcePolicyId: number;
@@ -466,17 +467,7 @@ async function syncUserPolicies(
.where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
const user = await findOrgUserByIdentifier(trx, orgId, username);
if (!user) {
logger.warn(
@@ -486,12 +477,12 @@ async function syncUserPolicies(
}
const alreadyExists = existingUserPolicies.some(
(up) => up.userId === user.user.userId
(up) => up.userId === user.userId
);
if (!alreadyExists) {
await trx.insert(userPolicies).values({
userId: user.user.userId,
userId: user.userId,
resourcePolicyId: policyId
});
}
@@ -536,17 +527,7 @@ async function addUserPolicies(
trx: Transaction
) {
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
const user = await findOrgUserByIdentifier(trx, orgId, username);
if (!user) {
logger.warn(
@@ -556,7 +537,7 @@ async function addUserPolicies(
}
await trx.insert(userPolicies).values({
userId: user.user.userId,
userId: user.userId,
resourcePolicyId: policyId
});
}
+27 -1
View File
@@ -29,8 +29,34 @@ export const SiteSchema = z.object({
"docker-socket-enabled": z.boolean().optional().default(true)
});
// A malformed hostname (e.g. stray whitespace) is silently accepted here but
// fails to parse as a URL when newt builds the health check request, which
// takes the target out of the routing pool and breaks the resource entirely
// (see #3677). Validate eagerly so blueprints reject it up front instead.
const healthCheckHostnameSchema = z
.string()
.trim()
.min(1)
.refine((val) => !/\s/.test(val), {
message: "Hostname must not contain whitespace"
})
.refine(
(val) => {
if (z.union([z.ipv4(), z.ipv6()]).safeParse(val).success) {
return true;
}
const hostnameRegex =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
return hostnameRegex.test(val);
},
{
message:
"Hostname must be a valid IP address or hostname (no spaces or invalid characters)"
}
);
export const TargetHealthCheckSchema = z.object({
hostname: z.string(),
hostname: healthCheckHostnameSchema,
port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true),
path: z.string().optional().default("/"),
@@ -88,7 +88,27 @@ export const queryAccessAuditLogsQuery = z.object({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.pipe(z.int().nonnegative()),
ip: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by IP adresses"
})
});
export const queryAccessAuditLogsParams = z.object({
@@ -134,6 +154,9 @@ function getWhere(data: Q) {
data.type ? eq(accessAuditLog.type, data.type) : undefined,
data.action !== undefined
? eq(accessAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(accessAuditLog.ip, data.ip)
: undefined
);
}
+2 -4
View File
@@ -16,12 +16,10 @@ import {
handleRemoteExitNodePingMessage
} from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws";
import {
handleConnectionLogMessage,
} from "#private/routers/newt";
import { handleConnectionLogMessage } from "#private/routers/newt";
export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
"newt/access-log": handleConnectionLogMessage,
"newt/access-log": handleConnectionLogMessage
};
@@ -81,7 +81,27 @@ export const queryAccessAuditLogsQuery = z.strictObject({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.pipe(z.int().nonnegative()),
ip: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by IP adresses"
})
});
export const queryRequestAuditLogsParams = z.object({
@@ -126,6 +146,9 @@ function getWhere(data: Q) {
data.path ? eq(requestAuditLog.path, data.path) : undefined,
data.action !== undefined
? eq(requestAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(requestAuditLog.ip, data.ip)
: undefined
);
}
+9 -4
View File
@@ -533,18 +533,23 @@ export async function startAuthentication(
// If email is provided, get security keys for that specific user
if (email) {
const [user] = await db
const matchingUsers = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
.where(
and(
eq(users.email, email.toLowerCase()),
eq(users.type, UserType.Internal)
)
);
if (!user || user.type !== UserType.Internal) {
if (matchingUsers.length !== 1) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials")
);
}
const user = matchingUsers[0];
userId = user.userId;
const userSecurityKeys = await db
+49 -38
View File
@@ -42,54 +42,62 @@ export async function setServerAdmin(
const { email, password, setupToken } = parsedBody.data;
// Validate setup token
const [validToken] = await db
.select()
.from(setupTokens)
.where(
and(
eq(setupTokens.token, setupToken),
eq(setupTokens.used, false)
)
);
if (!validToken) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired setup token"
)
);
}
const [existing] = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (existing) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admin already exists"
)
);
}
const passwordHash = await hashPassword(password);
const userId = generateId(15);
await db.transaction(async (trx) => {
// Mark the token as used
await trx
const consumed = await trx
.update(setupTokens)
.set({
used: true,
dateUsed: moment().toISOString()
})
.where(eq(setupTokens.tokenId, validToken.tokenId));
.where(
and(
eq(setupTokens.token, setupToken),
eq(setupTokens.used, false)
)
)
.returning({ tokenId: setupTokens.tokenId });
if (!consumed.length) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Invalid setup token"
);
}
const [existingAdmin] = await trx
.select({ userId: users.userId })
.from(users)
.where(eq(users.serverAdmin, true))
.limit(1);
if (existingAdmin) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Server admin already exists"
);
}
const [existingUser] = await trx
.select({ userId: users.userId })
.from(users)
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
)
.limit(1);
if (existingUser) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists"
);
}
// Create the server admin user
await trx.insert(users).values({
userId: userId,
email: email,
@@ -111,6 +119,9 @@ export async function setServerAdmin(
status: HttpCode.OK
});
} catch (e) {
if (createHttpError.isHttpError(e)) {
return next(e);
}
logger.error(e);
return next(
createHttpError(
+1 -1
View File
@@ -48,7 +48,7 @@ export async function validateSetupToken(
return response<ValidateSetupTokenResponse>(res, {
data: {
valid: false,
message: "Invalid or expired setup token"
message: "Invalid setup token"
},
success: true,
error: false,
@@ -46,6 +46,7 @@ import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import IdpIdentifierChangeDialog from "@app/components/IdpIdentifierChangeDialog";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
@@ -75,6 +76,12 @@ export default function GeneralPage() {
>([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState("");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const [originalIdentifierPath, setOriginalIdentifierPath] = useState("");
const [identifierConfirmOpen, setIdentifierConfirmOpen] = useState(false);
const [pendingPayload, setPendingPayload] = useState<Record<
string,
unknown
> | null>(null);
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const [redirectUrl, setRedirectUrl] = useState(
@@ -184,6 +191,9 @@ export default function GeneralPage() {
const data = res.data.data;
const roleMapping = data.idpOrg.roleMapping;
const idpVariant = data.idpOidcConfig?.variant || "oidc";
setOriginalIdentifierPath(
data.idpOidcConfig?.identifierPath ?? "sub"
);
setRedirectUrl(res.data.data.redirectUrl);
// Set the variant
@@ -378,18 +388,56 @@ export default function GeneralPage() {
};
}
const res = await api.post(
`/org/${orgId}/idp/${idpId}/oidc`,
payload
);
const nextIdentifierPath =
variant === "oidc"
? (data as OidcFormValues).identifierPath
: undefined;
if (res.status === 200) {
toast({
title: t("success"),
description: t("idpUpdatedDescription")
});
router.refresh();
if (
typeof nextIdentifierPath === "string" &&
nextIdentifierPath !== originalIdentifierPath
) {
setPendingPayload(payload);
setIdentifierConfirmOpen(true);
return;
}
await persistIdp(payload);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
async function persistIdp(payload: Record<string, unknown>) {
const res = await api.post(`/org/${orgId}/idp/${idpId}/oidc`, payload);
if (res.status === 200) {
if (typeof payload.identifierPath === "string") {
setOriginalIdentifierPath(payload.identifierPath);
}
toast({
title: t("success"),
description: t("idpUpdatedDescription")
});
router.refresh();
}
}
async function confirmIdentifierChange() {
if (!pendingPayload) {
return;
}
setLoading(true);
try {
await persistIdp(pendingPayload);
setPendingPayload(null);
} catch (e) {
toast({
title: t("error"),
@@ -407,6 +455,16 @@ export default function GeneralPage() {
return (
<>
<IdpIdentifierChangeDialog
open={identifierConfirmOpen}
setOpen={(open) => {
setIdentifierConfirmOpen(open);
if (!open) {
setPendingPayload(null);
}
}}
onConfirm={confirmIdentifierChange}
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
+46 -13
View File
@@ -12,6 +12,7 @@ import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
@@ -26,6 +27,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { logQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
export default function GeneralPage() {
const router = useRouter();
@@ -45,12 +47,14 @@ export default function GeneralPage() {
resourceId?: string;
location?: string;
actor?: string;
ip?: string[];
}>({
action: searchParams.get("action") || undefined,
type: searchParams.get("type") || undefined,
resourceId: searchParams.get("resourceId") || undefined,
location: searchParams.get("location") || undefined,
actor: searchParams.get("actor") || undefined
actor: searchParams.get("actor") || undefined,
ip: searchParams.getAll("ip") || undefined
});
const [currentPage, setCurrentPage] = useState<number>(0);
@@ -176,7 +180,7 @@ export default function GeneralPage() {
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
value: string | string[] | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
@@ -194,10 +198,13 @@ export default function GeneralPage() {
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.delete(key);
if (typeof value === "string") {
params.set(key, value);
} else {
params.delete(key);
} else if (typeof value !== "undefined" && "length" in value) {
for (const element of value) {
params.append(key, element);
}
}
});
router.replace(`?${params.toString()}`, { scroll: false });
@@ -205,6 +212,7 @@ export default function GeneralPage() {
const exportData = async () => {
try {
const { ip, ...restFilters } = filters;
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -212,13 +220,20 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
...restFilters
};
const response = await api.get(`/org/${orgId}/logs/access/export`, {
responseType: "blob",
params
});
// axios serializes arrays as `ip[]=…`, which express's query
// parser does not read back as `ip`, so pass them in the URL
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
const response = await api.get(
`/org/${orgId}/logs/access/export?${sp.toString()}`,
{
responseType: "blob",
params
}
);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
@@ -297,7 +312,24 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: () => <span className="px-2">{t("ip")}</span>,
header: () => (
<span className="px-2">
<ColumnMultiFilterButton
options={(filters.ip ?? []).map((ip) => ({
label: ip,
value: ip
}))}
label={t("ip")}
allowArbitraryValues
searchPlaceholder={t("ipFilterSearchPlaceholder")}
emptyMessage={t("ipFilterEmptyMessage")}
selectedValues={filters.ip ?? []}
onSelectedValuesChange={(value) =>
handleFilterChange("ip", value)
}
/>
</span>
),
cell: ({ row }) => {
return row.original.ip ? (
row.original.ip
@@ -315,7 +347,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map(
(location) => ({
value: location,
label: location
label: `${location} ${countryCodeToFlagEmoji(location)}`
})
)}
label={t("location")}
@@ -334,7 +366,8 @@ export default function GeneralPage() {
<span className="flex items-center gap-1">
{row.original.location ? (
<span className="text-muted-foreground text-xs">
{row.original.location}
{row.original.location}{" "}
{countryCodeToFlagEmoji(row.original.location)}
</span>
) : (
<span className="text-muted-foreground text-xs">
+40 -10
View File
@@ -23,6 +23,8 @@ import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
export default function GeneralPage() {
const router = useRouter();
@@ -47,6 +49,7 @@ export default function GeneralPage() {
method?: string;
reason?: string;
path?: string;
ip?: string[];
}>({
action: searchParams.get("action") || undefined,
host: searchParams.get("host") || undefined,
@@ -55,7 +58,8 @@ export default function GeneralPage() {
actor: searchParams.get("actor") || undefined,
method: searchParams.get("method") || undefined,
reason: searchParams.get("reason") || undefined,
path: searchParams.get("path") || undefined
path: searchParams.get("path") || undefined,
ip: searchParams.getAll("ip") || undefined
});
const getDefaultDateRange = () => {
@@ -179,7 +183,7 @@ export default function GeneralPage() {
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
value: string | string[] | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
@@ -197,10 +201,13 @@ export default function GeneralPage() {
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.delete(key);
if (typeof value === "string") {
params.set(key, value);
} else {
params.delete(key);
} else if (typeof value !== "undefined" && "length" in value) {
for (const element of value) {
params.append(key, element);
}
}
});
router.replace(`?${params.toString()}`, { scroll: false });
@@ -209,6 +216,7 @@ export default function GeneralPage() {
const exportData = async () => {
try {
// Prepare query params for export
const { ip, ...restFilters } = filters;
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -216,11 +224,15 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
...restFilters
};
// axios serializes arrays as `ip[]=…`, which express's query
// parser does not read back as `ip`, so pass them in the URL
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
const response = await api.get(
`/org/${orgId}/logs/request/export`,
`/org/${orgId}/logs/request/export?${sp.toString()}`,
{
responseType: "blob",
params
@@ -351,7 +363,24 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: ({ column }) => <span className="px-2">{t("ip")}</span>,
header: ({ column }) => (
<span className="px-2">
<ColumnMultiFilterButton
options={(filters.ip ?? []).map((ip) => ({
label: ip,
value: ip
}))}
label={t("ip")}
allowArbitraryValues
searchPlaceholder={t("ipFilterSearchPlaceholder")}
emptyMessage={t("ipFilterEmptyMessage")}
selectedValues={filters.ip ?? []}
onSelectedValuesChange={(value) =>
handleFilterChange("ip", value)
}
/>
</span>
),
cell: ({ row }) => {
return row.original.ip ? (
row.original.ip
@@ -369,7 +398,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map(
(location) => ({
value: location,
label: location
label: `${location} ${countryCodeToFlagEmoji(location)}`
})
)}
selectedValue={filters.location}
@@ -389,7 +418,8 @@ export default function GeneralPage() {
<span className="flex items-center gap-1">
{row.original.location ? (
<span className="text-muted-foreground text-xs">
{row.original.location}
{row.original.location}{" "}
{countryCodeToFlagEmoji(row.original.location)}
</span>
) : (
<span className="text-muted-foreground text-xs">
+68 -7
View File
@@ -41,6 +41,7 @@ import {
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import IdpIdentifierChangeDialog from "@app/components/IdpIdentifierChangeDialog";
import { useTranslations } from "next-intl";
export default function GeneralPage() {
@@ -51,6 +52,12 @@ export default function GeneralPage() {
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const [originalIdentifierPath, setOriginalIdentifierPath] = useState("");
const [identifierConfirmOpen, setIdentifierConfirmOpen] = useState(false);
const [pendingPayload, setPendingPayload] = useState<Record<
string,
unknown
> | null>(null);
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const t = useTranslations();
@@ -141,6 +148,9 @@ export default function GeneralPage() {
| "google"
| "azure") || "oidc";
setVariant(idpVariant);
setOriginalIdentifierPath(
data.idpOidcConfig?.identifierPath ?? "sub"
);
let tenantId = "";
if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) {
@@ -258,15 +268,56 @@ export default function GeneralPage() {
};
}
const res = await api.post(`/idp/${idpId}/oidc`, payload);
const nextIdentifierPath =
variant === "oidc"
? (data as OidcFormValues).identifierPath
: undefined;
if (res.status === 200) {
toast({
title: t("success"),
description: t("idpUpdatedDescription")
});
router.refresh();
if (
typeof nextIdentifierPath === "string" &&
nextIdentifierPath !== originalIdentifierPath
) {
setPendingPayload(payload);
setIdentifierConfirmOpen(true);
return;
}
await persistIdp(payload);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
async function persistIdp(payload: Record<string, unknown>) {
const res = await api.post(`/idp/${idpId}/oidc`, payload);
if (res.status === 200) {
if (typeof payload.identifierPath === "string") {
setOriginalIdentifierPath(payload.identifierPath);
}
toast({
title: t("success"),
description: t("idpUpdatedDescription")
});
router.refresh();
}
}
async function confirmIdentifierChange() {
if (!pendingPayload) {
return;
}
setLoading(true);
try {
await persistIdp(pendingPayload);
setPendingPayload(null);
} catch (e) {
toast({
title: t("error"),
@@ -284,6 +335,16 @@ export default function GeneralPage() {
return (
<>
<IdpIdentifierChangeDialog
open={identifierConfirmOpen}
setOpen={(open) => {
setIdentifierConfirmOpen(open);
if (!open) {
setPendingPayload(null);
}
}}
onConfirm={confirmIdentifierChange}
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
+5 -3
View File
@@ -21,7 +21,7 @@ import { useTranslations } from "next-intl";
interface FilterOption {
value: string;
label: string;
label: React.ReactNode;
}
interface ColumnFilterButtonProps {
@@ -32,6 +32,7 @@ interface ColumnFilterButtonProps {
emptyMessage?: string;
className?: string;
label: string;
allowArbitraryValues?: boolean;
}
export function ColumnFilterButton({
@@ -41,7 +42,8 @@ export function ColumnFilterButton({
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
label
label,
allowArbitraryValues
}: ColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
@@ -101,7 +103,7 @@ export function ColumnFilterButton({
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
value={option.value}
onSelect={() => {
onValueChange(
selectedValue === option.value
+24 -4
View File
@@ -35,6 +35,7 @@ type ColumnMultiFilterButtonProps = {
emptyMessage?: string;
className?: string;
label: string;
allowArbitraryValues?: boolean;
};
export function ColumnMultiFilterButton({
@@ -44,11 +45,26 @@ export function ColumnMultiFilterButton({
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
label
label,
allowArbitraryValues
}: ColumnMultiFilterButtonProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const t = useTranslations();
const visibleOptions = useMemo<FilterOption[]>(() => {
const newOptions = [...options];
if (allowArbitraryValues && searchQuery.trim().length > 0) {
newOptions.push({
label: searchQuery,
value: searchQuery
});
}
return newOptions;
}, [options, allowArbitraryValues, searchQuery]);
const selectedSet = useMemo(
() => new Set(selectedValues),
[selectedValues]
@@ -64,7 +80,7 @@ export function ColumnMultiFilterButton({
selectedValues[0]
);
}
return t("accessUsersRoleFilterCount", {
return t("multiSelectFilterCount", {
count: selectedValues.length
});
}, [selectedValues, options, t]);
@@ -108,7 +124,11 @@ export function ColumnMultiFilterButton({
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandInput
placeholder={searchPlaceholder}
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
@@ -123,7 +143,7 @@ export function ColumnMultiFilterButton({
{t("accessFilterClear")}
</CommandItem>
)}
{options.map((option) => (
{visibleOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
+5 -1
View File
@@ -172,7 +172,6 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
.nullable()
.optional(),
hcScheme: z.string().optional(),
hcHostname: z.string(),
hcPort: z
.string()
.min(1, { message: t("healthCheckPortInvalid") })
@@ -184,6 +183,11 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
{ message: t("healthCheckPortInvalid") }
),
hcFollowRedirects: z.boolean(),
hcHostname: z
.string()
.refine((val) => !/\s/.test(val), {
message: t("healthCheckHostnameInvalid")
}),
hcMode: z.string(),
hcUnhealthyInterval: z.int().positive().min(5),
hcTlsServerName: z.string(),
@@ -0,0 +1,35 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useTranslations } from "next-intl";
type IdpIdentifierChangeDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
onConfirm: () => Promise<void>;
};
export default function IdpIdentifierChangeDialog({
open,
setOpen,
onConfirm
}: IdpIdentifierChangeDialogProps) {
const t = useTranslations();
return (
<ConfirmDeleteDialog
open={open}
setOpen={setOpen}
dialog={
<div className="space-y-2">
<p>{t("idpIdentifierChangeDescription")}</p>
</div>
}
buttonText={t("saveGeneralSettings")}
onConfirm={onConfirm}
string={t("idpIdentifierChangeConfirmMessage")}
title={t("idpIdentifierChangeTitle")}
warningText={t("idpIdentifierChangeWarningText")}
/>
);
}
+30 -22
View File
@@ -1,3 +1,8 @@
import {
getAiBudgetScopeListPath,
type AiBudgetScope
} from "@app/lib/aiBudgetScope";
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import { build } from "@server/build";
@@ -5,15 +10,21 @@ import {
StatusHistoryResponse,
type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
import type {
ListAiModelsResponse,
ListAiProvidersResponse,
ListCatalogModelsResponse
} from "@server/routers/aiProvider/types";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type {
QueryRequestAnalyticsResponse,
QueryAiUsageFilterOptionsResponse,
QueryAiUsageOverviewResponse,
QueryAiUsageProvidersResponse,
QueryAiUsageResourcesResponse,
QueryAiUsageUsersRolesResponse,
QueryAiUsageVirtualApiKeysResponse
QueryAiUsageVirtualApiKeysResponse,
QueryRequestAnalyticsResponse
} from "@server/routers/auditLogs";
import type {
QueryAccessAuditLogResponse,
@@ -34,6 +45,7 @@ import type {
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
import type {
LauncherResource,
ListLauncherGroupsResponse,
@@ -43,9 +55,8 @@ import type {
ListLauncherSitesResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type {
GetResourcePoliciesResponse,
GetResourceWhitelistResponse,
@@ -59,7 +70,6 @@ import type {
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site";
import type {
@@ -71,18 +81,8 @@ import type {
} from "@server/routers/siteResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { ListTargetsResponse } from "@server/routers/target";
import type {
ListAiModelsResponse,
ListAiProvidersResponse,
ListCatalogModelsResponse
} from "@server/routers/aiProvider/types";
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
import {
getAiBudgetScopeListPath,
type AiBudgetScope
} from "@app/lib/aiBudgetScope";
import type { ListUsersResponse } from "@server/routers/user";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type ResponseT from "@server/types/Response";
import {
infiniteQueryOptions,
@@ -1000,7 +1000,8 @@ export const httpLogsFiltersSchema = z.object({
actor: z.string().optional().catch(undefined),
method: z.string().optional().catch(undefined),
reason: z.string().optional().catch(undefined),
path: z.string().optional().catch(undefined)
path: z.string().optional().catch(undefined),
ip: z.array(z.string()).optional().catch(undefined)
});
export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>;
@@ -1026,7 +1027,8 @@ export const accessLogsFiltersSchema = z.object({
action: z.string().optional().catch(undefined),
location: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined),
type: z.string().optional().catch(undefined)
type: z.string().optional().catch(undefined),
ip: z.array(z.string()).optional().catch(undefined)
});
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
@@ -1139,10 +1141,13 @@ export const logQueries = {
queryOptions({
queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const { page, pageSize, ip, ...rest } = filters;
const sp = new URLSearchParams(
(ip ?? []).map((ip) => ["ip", ip])
);
const res = await meta!.api.get<
AxiosResponse<QueryRequestAuditLogResponse>
>(`/org/${orgId}/logs/request`, {
>(`/org/${orgId}/logs/request?${sp.toString()}`, {
params: {
...rest,
limit: pageSize,
@@ -1164,10 +1169,13 @@ export const logQueries = {
queryOptions({
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const { page, pageSize, ip, ...rest } = filters;
const sp = new URLSearchParams(
(ip ?? []).map((ip) => ["ip", ip])
);
const res = await meta!.api.get<
AxiosResponse<QueryAccessAuditLogResponse>
>(`/org/${orgId}/logs/access`, {
>(`/org/${orgId}/logs/access?${sp.toString()}`, {
params: {
...rest,
limit: pageSize,