mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 05:26:32 +02:00
Compare commits
11 Commits
517e1d15c8
...
b6d688f15e
| Author | SHA1 | Date | |
|---|---|---|---|
| b6d688f15e | |||
| 8a57d8dd9c | |||
| 8e0e32c2be | |||
| 6b3a0a2113 | |||
| 4d6ed7eec5 | |||
| 1625dd1add | |||
| 605dd2f3c9 | |||
| 51bb149fd5 | |||
| 2ae4c29418 | |||
| ba71016f87 | |||
| 85c2bd807e |
@@ -1,54 +1,47 @@
|
||||
api:
|
||||
insecure: true
|
||||
dashboard: true
|
||||
|
||||
providers:
|
||||
http:
|
||||
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
||||
pollInterval: "5s"
|
||||
endpoint: http://pangolin:3001/api/v1/traefik-config
|
||||
pollInterval: 5s
|
||||
file:
|
||||
filename: "/etc/traefik/dynamic_config.yml"
|
||||
|
||||
filename: /etc/traefik/dynamic_config.yml
|
||||
experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "{{.BadgerVersion}}"
|
||||
|
||||
moduleName: github.com/fosrl/badger
|
||||
version: v1.4.1
|
||||
log:
|
||||
level: "INFO"
|
||||
format: "common"
|
||||
level: INFO
|
||||
format: common
|
||||
maxSize: 100
|
||||
maxBackups: 3
|
||||
maxAge: 3
|
||||
compress: true
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
email: "{{.LetsEncryptEmail}}"
|
||||
storage: "/letsencrypt/acme.json"
|
||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||
|
||||
email: '{{.LetsEncryptEmail}}'
|
||||
storage: /letsencrypt/acme.json
|
||||
caServer: https://acme-v02.api.letsencrypt.org/directory
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
address: ':80'
|
||||
websecure:
|
||||
address: ":443"
|
||||
address: ':443'
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
readTimeout: 30m
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
certResolver: letsencrypt
|
||||
encodedCharacters:
|
||||
allowEncodedSlash: true
|
||||
allowEncodedQuestionMark: true
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
|
||||
ping:
|
||||
entryPoint: "web"
|
||||
entryPoint: web
|
||||
|
||||
+5
-2
@@ -872,6 +872,7 @@
|
||||
"resourcePolicyOtpEmpty": "No one time password",
|
||||
"resourcePolicyReadOnly": "This policy is Read only",
|
||||
"resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it on this page.",
|
||||
"editSharedPolicy": "Edit Shared Policy",
|
||||
"resourcePolicyTypeSave": "Save Resource type",
|
||||
"resourcePolicySelect": "Select resource policy",
|
||||
"resourcePolicySelectError": "Select a resource policy",
|
||||
@@ -918,7 +919,7 @@
|
||||
"resourcePolicyInline": "Inline Resource Policy",
|
||||
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
||||
"resourcePolicyShared": "Shared Resource Policy",
|
||||
"resourcePolicySharedDescription": "Access Policy shared accross multiple resources",
|
||||
"resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.",
|
||||
"resourceUsersRoles": "Access Controls",
|
||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||
@@ -3430,5 +3431,7 @@
|
||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||
"memberPortalPrevious": "Previous",
|
||||
"memberPortalNext": "Next",
|
||||
"httpSettings": "HTTP Settings"
|
||||
"httpSettings": "HTTP Settings",
|
||||
"tcpSettings": "TCP Settings",
|
||||
"udpSettings": "UDP Settings"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ export async function createResourceSession(opts: {
|
||||
userSessionId?: string | null;
|
||||
whitelistId?: number | null;
|
||||
accessTokenId?: string | null;
|
||||
policyPasswordId?: number | null;
|
||||
policyPincodeId?: number | null;
|
||||
policyWhitelistId?: number | null;
|
||||
doNotExtend?: boolean;
|
||||
expiresAt?: number | null;
|
||||
sessionLength?: number | null;
|
||||
@@ -28,7 +31,10 @@ export async function createResourceSession(opts: {
|
||||
!opts.pincodeId &&
|
||||
!opts.whitelistId &&
|
||||
!opts.accessTokenId &&
|
||||
!opts.userSessionId
|
||||
!opts.userSessionId &&
|
||||
!opts.policyPasswordId &&
|
||||
!opts.policyPincodeId &&
|
||||
!opts.policyWhitelistId
|
||||
) {
|
||||
throw new Error("Auth method must be provided");
|
||||
}
|
||||
@@ -49,6 +55,9 @@ export async function createResourceSession(opts: {
|
||||
whitelistId: opts.whitelistId || null,
|
||||
doNotExtend: opts.doNotExtend || false,
|
||||
accessTokenId: opts.accessTokenId || null,
|
||||
policyPasswordId: opts.policyPasswordId || null,
|
||||
policyPincodeId: opts.policyPincodeId || null,
|
||||
policyWhitelistId: opts.policyWhitelistId || null,
|
||||
isRequestToken: opts.isRequestToken || false,
|
||||
userSessionId: opts.userSessionId || null,
|
||||
issuedAt: new Date().getTime()
|
||||
|
||||
@@ -820,6 +820,24 @@ export const resourceSessions = pgTable("resourceSessions", {
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
policyPasswordId: integer("policyPasswordId").references(
|
||||
() => resourcePolicyPassword.passwordId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
policyPincodeId: integer("policyPincodeId").references(
|
||||
() => resourcePolicyPincode.pincodeId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
policyWhitelistId: integer("policyWhitelistId").references(
|
||||
() => resourcePolicyWhiteList.whitelistId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
issuedAt: bigint("issuedAt", { mode: "number" })
|
||||
});
|
||||
|
||||
|
||||
@@ -1148,6 +1148,24 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
policyPasswordId: integer("policyPasswordId").references(
|
||||
() => resourcePolicyPassword.passwordId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
policyPincodeId: integer("policyPincodeId").references(
|
||||
() => resourcePolicyPincode.pincodeId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
policyWhitelistId: integer("policyWhitelistId").references(
|
||||
() => resourcePolicyWhiteList.whitelistId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
issuedAt: integer("issuedAt")
|
||||
});
|
||||
|
||||
|
||||
@@ -520,7 +520,8 @@ export class TraefikConfigManager {
|
||||
build != "oss", // generate the login pages on the cloud and hybrid,
|
||||
build == "saas"
|
||||
? false
|
||||
: config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config
|
||||
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
|
||||
build != "oss" // generate browser gateway targets on cloud and enterprise
|
||||
);
|
||||
|
||||
const domains = new Set<string>();
|
||||
|
||||
@@ -85,7 +85,8 @@ export async function getTraefikConfig(
|
||||
filterOutNamespaceDomains = false,
|
||||
generateLoginPageRouters = false,
|
||||
allowRawResources = true,
|
||||
allowMaintenancePage = true
|
||||
allowMaintenancePage = true,
|
||||
allowBrowserGatewayResources = true
|
||||
): Promise<any> {
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
@@ -276,64 +277,6 @@ export async function getTraefikConfig(
|
||||
});
|
||||
});
|
||||
|
||||
// Query browser gateway targets for this exit node
|
||||
const browserGatewayRows = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
resourceName: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
wildcard: resources.wildcard,
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert,
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||
// Maintenance fields
|
||||
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
||||
maintenanceModeType: resources.maintenanceModeType,
|
||||
maintenanceTitle: resources.maintenanceTitle,
|
||||
maintenanceMessage: resources.maintenanceMessage,
|
||||
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
|
||||
// Browser gateway target fields
|
||||
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
|
||||
bgType: browserGatewayTarget.type,
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
siteExitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
||||
)
|
||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domainNamespaces.domainId, resources.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.enabled, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
and(
|
||||
isNull(sites.exitNodeId),
|
||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||
eq(sites.type, "local"),
|
||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||
)
|
||||
),
|
||||
inArray(sites.type, siteTypes)
|
||||
)
|
||||
);
|
||||
|
||||
// Group browser gateway targets by resource
|
||||
type BrowserGatewayResourceEntry = {
|
||||
resourceId: number;
|
||||
@@ -366,39 +309,100 @@ export async function getTraefikConfig(
|
||||
BrowserGatewayResourceEntry
|
||||
>();
|
||||
|
||||
for (const row of browserGatewayRows) {
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
continue;
|
||||
}
|
||||
if (!browserGatewayResourcesMap.has(row.resourceId)) {
|
||||
browserGatewayResourcesMap.set(row.resourceId, {
|
||||
resourceId: row.resourceId,
|
||||
name: sanitize(row.resourceName) || "",
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
wildcard: row.wildcard,
|
||||
domainCertResolver: row.domainCertResolver,
|
||||
preferWildcardCert: row.preferWildcardCert,
|
||||
maintenanceModeEnabled: row.maintenanceModeEnabled,
|
||||
maintenanceModeType: row.maintenanceModeType,
|
||||
maintenanceTitle: row.maintenanceTitle,
|
||||
maintenanceMessage: row.maintenanceMessage,
|
||||
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
|
||||
targets: []
|
||||
if (allowBrowserGatewayResources) {
|
||||
// Query browser gateway targets for this exit node
|
||||
const browserGatewayRows = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
resourceName: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
wildcard: resources.wildcard,
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert,
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||
// Maintenance fields
|
||||
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
||||
maintenanceModeType: resources.maintenanceModeType,
|
||||
maintenanceTitle: resources.maintenanceTitle,
|
||||
maintenanceMessage: resources.maintenanceMessage,
|
||||
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
|
||||
// Browser gateway target fields
|
||||
browserGatewayTargetId:
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
bgType: browserGatewayTarget.type,
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
siteExitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
||||
)
|
||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domainNamespaces.domainId, resources.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.enabled, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
and(
|
||||
isNull(sites.exitNodeId),
|
||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||
eq(sites.type, "local"),
|
||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||
)
|
||||
),
|
||||
inArray(sites.type, siteTypes)
|
||||
)
|
||||
);
|
||||
|
||||
for (const row of browserGatewayRows) {
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
continue;
|
||||
}
|
||||
if (!browserGatewayResourcesMap.has(row.resourceId)) {
|
||||
browserGatewayResourcesMap.set(row.resourceId, {
|
||||
resourceId: row.resourceId,
|
||||
name: sanitize(row.resourceName) || "",
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
wildcard: row.wildcard,
|
||||
domainCertResolver: row.domainCertResolver,
|
||||
preferWildcardCert: row.preferWildcardCert,
|
||||
maintenanceModeEnabled: row.maintenanceModeEnabled,
|
||||
maintenanceModeType: row.maintenanceModeType,
|
||||
maintenanceTitle: row.maintenanceTitle,
|
||||
maintenanceMessage: row.maintenanceMessage,
|
||||
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
|
||||
targets: []
|
||||
});
|
||||
}
|
||||
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
||||
browserGatewayTargetId: row.browserGatewayTargetId,
|
||||
bgType: row.bgType,
|
||||
siteId: row.siteId,
|
||||
siteType: row.siteType,
|
||||
siteOnline: row.siteOnline,
|
||||
subnet: row.subnet,
|
||||
siteExitNodeId: row.siteExitNodeId
|
||||
});
|
||||
}
|
||||
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
||||
browserGatewayTargetId: row.browserGatewayTargetId,
|
||||
bgType: row.bgType,
|
||||
siteId: row.siteId,
|
||||
siteType: row.siteType,
|
||||
siteOnline: row.siteOnline,
|
||||
subnet: row.subnet,
|
||||
siteExitNodeId: row.siteExitNodeId
|
||||
});
|
||||
}
|
||||
|
||||
let siteResourcesWithFullDomain: {
|
||||
@@ -1055,245 +1059,257 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Traefik config for browser gateway resources
|
||||
const browserGatewayPort = 39999;
|
||||
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
|
||||
if (!bgResource.enabled) continue;
|
||||
if (!bgResource.domainId) continue;
|
||||
if (!bgResource.fullDomain) continue;
|
||||
if (allowBrowserGatewayResources) {
|
||||
// Generate Traefik config for browser gateway resources
|
||||
const browserGatewayPort = 39999;
|
||||
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
|
||||
if (!bgResource.enabled) continue;
|
||||
if (!bgResource.domainId) continue;
|
||||
if (!bgResource.fullDomain) continue;
|
||||
|
||||
if (!config_output.http.routers) config_output.http.routers = {};
|
||||
if (!config_output.http.services) config_output.http.services = {};
|
||||
if (!config_output.http.routers) config_output.http.routers = {};
|
||||
if (!config_output.http.services) config_output.http.services = {};
|
||||
|
||||
const fullDomain = bgResource.fullDomain;
|
||||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
const routerMiddlewares = [
|
||||
badgerMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
];
|
||||
const fullDomain = bgResource.fullDomain;
|
||||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
const routerMiddlewares = [
|
||||
badgerMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
const hostRule = `Host(\`${fullDomain}\`)`;
|
||||
const hostRule = `Host(\`${fullDomain}\`)`;
|
||||
|
||||
// Build TLS config
|
||||
let tls = {};
|
||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
const domainParts = fullDomain.split(".");
|
||||
let wildCard: string;
|
||||
if (domainParts.length <= 2) {
|
||||
wildCard = `*.${domainParts.join(".")}`;
|
||||
// Build TLS config
|
||||
let tls = {};
|
||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
const domainParts = fullDomain.split(".");
|
||||
let wildCard: string;
|
||||
if (domainParts.length <= 2) {
|
||||
wildCard = `*.${domainParts.join(".")}`;
|
||||
} else {
|
||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||
}
|
||||
if (!bgResource.subdomain) {
|
||||
wildCard = fullDomain;
|
||||
}
|
||||
|
||||
const globalDefaultResolver =
|
||||
config.getRawConfig().traefik.cert_resolver;
|
||||
const globalDefaultPreferWildcard =
|
||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||
const resolverName = bgResource.domainCertResolver
|
||||
? bgResource.domainCertResolver.trim()
|
||||
: globalDefaultResolver;
|
||||
const preferWildcard =
|
||||
bgResource.preferWildcardCert !== undefined &&
|
||||
bgResource.preferWildcardCert !== null
|
||||
? bgResource.preferWildcardCert
|
||||
: globalDefaultPreferWildcard;
|
||||
|
||||
tls = {
|
||||
certResolver: resolverName,
|
||||
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
|
||||
};
|
||||
} else {
|
||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||
}
|
||||
if (!bgResource.subdomain) {
|
||||
wildCard = fullDomain;
|
||||
}
|
||||
|
||||
const globalDefaultResolver =
|
||||
config.getRawConfig().traefik.cert_resolver;
|
||||
const globalDefaultPreferWildcard =
|
||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||
const resolverName = bgResource.domainCertResolver
|
||||
? bgResource.domainCertResolver.trim()
|
||||
: globalDefaultResolver;
|
||||
const preferWildcard =
|
||||
bgResource.preferWildcardCert !== undefined &&
|
||||
bgResource.preferWildcardCert !== null
|
||||
? bgResource.preferWildcardCert
|
||||
: globalDefaultPreferWildcard;
|
||||
|
||||
tls = {
|
||||
certResolver: resolverName,
|
||||
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
|
||||
};
|
||||
} else {
|
||||
const matchingCert = validCerts.find(
|
||||
(cert) => cert.queriedDomain === fullDomain
|
||||
);
|
||||
if (!matchingCert) {
|
||||
logger.debug(
|
||||
`No matching certificate found for browser gateway domain: ${fullDomain}`
|
||||
const matchingCert = validCerts.find(
|
||||
(cert) => cert.queriedDomain === fullDomain
|
||||
);
|
||||
if (!matchingCert) {
|
||||
logger.debug(
|
||||
`No matching certificate found for browser gateway domain: ${fullDomain}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
|
||||
|
||||
if (bgResource.ssl) {
|
||||
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
|
||||
config_output.http.routers![redirectRouterName] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: bgUiServiceName,
|
||||
rule: hostRule,
|
||||
priority: 100
|
||||
};
|
||||
}
|
||||
|
||||
// Collect online sites for this resource (for any type)
|
||||
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
|
||||
|
||||
// Maintenance page logic for browser gateway resources
|
||||
let showBgMaintenancePage = false;
|
||||
if (bgResource.maintenanceModeEnabled) {
|
||||
if (bgResource.maintenanceModeType === "forced") {
|
||||
showBgMaintenancePage = true;
|
||||
} else if (bgResource.maintenanceModeType === "automatic") {
|
||||
showBgMaintenancePage = !anySiteOnline;
|
||||
}
|
||||
}
|
||||
|
||||
if (showBgMaintenancePage && allowMaintenancePage) {
|
||||
const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`;
|
||||
const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`;
|
||||
const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`;
|
||||
|
||||
const entrypointHttp =
|
||||
config.getRawConfig().traefik.http_entrypoint;
|
||||
const entrypointHttps =
|
||||
config.getRawConfig().traefik.https_entrypoint;
|
||||
|
||||
const maintenancePort = config.getRawConfig().server.next_port;
|
||||
const maintenanceHost =
|
||||
config.getRawConfig().server.internal_hostname;
|
||||
|
||||
if (!config_output.http.services)
|
||||
config_output.http.services = {};
|
||||
if (!config_output.http.middlewares)
|
||||
config_output.http.middlewares = {};
|
||||
if (!config_output.http.routers)
|
||||
config_output.http.routers = {};
|
||||
|
||||
config_output.http.services![bgMaintenanceServiceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `http://${maintenanceHost}:${maintenancePort}`
|
||||
}
|
||||
],
|
||||
passHostHeader: true
|
||||
}
|
||||
};
|
||||
|
||||
config_output.http.middlewares![bgRewriteMiddlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: "^/(.*)",
|
||||
replacement: "/maintenance-screen"
|
||||
}
|
||||
};
|
||||
|
||||
config_output.http.routers![bgMaintenanceRouterName] = {
|
||||
entryPoints: [
|
||||
bgResource.ssl ? entrypointHttps : entrypointHttp
|
||||
],
|
||||
service: bgMaintenanceServiceName,
|
||||
middlewares: [bgRewriteMiddlewareName],
|
||||
rule: hostRule,
|
||||
priority: 2000,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
config_output.http.routers![
|
||||
`${bgMaintenanceRouterName}-assets`
|
||||
] = {
|
||||
entryPoints: [
|
||||
bgResource.ssl ? entrypointHttps : entrypointHttp
|
||||
],
|
||||
service: bgMaintenanceServiceName,
|
||||
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 2001,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
|
||||
|
||||
if (bgResource.ssl) {
|
||||
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
|
||||
config_output.http.routers![redirectRouterName] = {
|
||||
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: bgUiServiceName,
|
||||
rule: hostRule,
|
||||
priority: 100
|
||||
};
|
||||
}
|
||||
|
||||
// Collect online sites for this resource (for any type)
|
||||
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
|
||||
|
||||
// Maintenance page logic for browser gateway resources
|
||||
let showBgMaintenancePage = false;
|
||||
if (bgResource.maintenanceModeEnabled) {
|
||||
if (bgResource.maintenanceModeType === "forced") {
|
||||
showBgMaintenancePage = true;
|
||||
} else if (bgResource.maintenanceModeType === "automatic") {
|
||||
showBgMaintenancePage = !anySiteOnline;
|
||||
// Group targets by type and generate per-type websocket routers and services
|
||||
const typeMap = new Map<string, typeof bgResource.targets>();
|
||||
for (const t of bgResource.targets) {
|
||||
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
|
||||
typeMap.get(t.bgType)!.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
if (showBgMaintenancePage && allowMaintenancePage) {
|
||||
const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`;
|
||||
const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`;
|
||||
const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`;
|
||||
for (const [bgType, typedTargets] of typeMap.entries()) {
|
||||
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
|
||||
const bgRouterName = `${bgKey}-router`;
|
||||
const bgServiceName = `${bgKey}-service`;
|
||||
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
|
||||
|
||||
const entrypointHttp =
|
||||
config.getRawConfig().traefik.http_entrypoint;
|
||||
const entrypointHttps =
|
||||
config.getRawConfig().traefik.https_entrypoint;
|
||||
const servers = typedTargets
|
||||
.filter((t) => {
|
||||
if (!t.siteOnline && anySiteOnline) return false;
|
||||
if (t.siteType === "newt") return !!t.subnet;
|
||||
return false; // browser gateway only supported on newt sites
|
||||
})
|
||||
.map((t) => ({
|
||||
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
|
||||
}))
|
||||
.filter(
|
||||
(v, i, a) => a.findIndex((u) => u.url === v.url) === i
|
||||
);
|
||||
|
||||
const maintenancePort = config.getRawConfig().server.next_port;
|
||||
const maintenanceHost =
|
||||
config.getRawConfig().server.internal_hostname;
|
||||
|
||||
if (!config_output.http.services) config_output.http.services = {};
|
||||
if (!config_output.http.middlewares)
|
||||
config_output.http.middlewares = {};
|
||||
if (!config_output.http.routers) config_output.http.routers = {};
|
||||
|
||||
config_output.http.services![bgMaintenanceServiceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{ url: `http://${maintenanceHost}:${maintenancePort}` }
|
||||
config_output.http.routers![bgRouterName] = {
|
||||
entryPoints: [
|
||||
bgResource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
passHostHeader: true
|
||||
}
|
||||
};
|
||||
middlewares: routerMiddlewares,
|
||||
service: bgServiceName,
|
||||
rule: bgRule,
|
||||
priority: 110, // highest - websocket path takes precedence
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
config_output.http.middlewares![bgRewriteMiddlewareName] = {
|
||||
config_output.http.services![bgServiceName] = {
|
||||
loadBalancer: {
|
||||
servers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// UI: serve the browser gateway page from the internal pangolin instance.
|
||||
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
|
||||
// how the maintenance page rewrites everything to /maintenance-screen.
|
||||
const primaryType = typeMap.keys().next().value as string;
|
||||
const internalHost = config.getRawConfig().server.internal_hostname;
|
||||
const internalPort = config.getRawConfig().server.next_port;
|
||||
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
|
||||
const entrypoint = bgResource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint;
|
||||
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
|
||||
config_output.http.middlewares![uiRewriteMiddlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: "^/(.*)",
|
||||
replacement: "/maintenance-screen"
|
||||
replacement: `/${primaryType}`
|
||||
}
|
||||
};
|
||||
|
||||
config_output.http.routers![bgMaintenanceRouterName] = {
|
||||
entryPoints: [
|
||||
bgResource.ssl ? entrypointHttps : entrypointHttp
|
||||
],
|
||||
service: bgMaintenanceServiceName,
|
||||
middlewares: [bgRewriteMiddlewareName],
|
||||
rule: hostRule,
|
||||
priority: 2000,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
config_output.http.routers![`${bgMaintenanceRouterName}-assets`] = {
|
||||
entryPoints: [
|
||||
bgResource.ssl ? entrypointHttps : entrypointHttp
|
||||
],
|
||||
service: bgMaintenanceServiceName,
|
||||
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 2001,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Group targets by type and generate per-type websocket routers and services
|
||||
const typeMap = new Map<string, typeof bgResource.targets>();
|
||||
for (const t of bgResource.targets) {
|
||||
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
|
||||
typeMap.get(t.bgType)!.push(t);
|
||||
}
|
||||
|
||||
for (const [bgType, typedTargets] of typeMap.entries()) {
|
||||
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
|
||||
const bgRouterName = `${bgKey}-router`;
|
||||
const bgServiceName = `${bgKey}-service`;
|
||||
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
|
||||
|
||||
const servers = typedTargets
|
||||
.filter((t) => {
|
||||
if (!t.siteOnline && anySiteOnline) return false;
|
||||
if (t.siteType === "newt") return !!t.subnet;
|
||||
return false; // browser gateway only supported on newt sites
|
||||
})
|
||||
.map((t) => ({
|
||||
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
|
||||
}))
|
||||
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
|
||||
|
||||
config_output.http.routers![bgRouterName] = {
|
||||
entryPoints: [
|
||||
bgResource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: routerMiddlewares,
|
||||
service: bgServiceName,
|
||||
rule: bgRule,
|
||||
priority: 110, // highest - websocket path takes precedence
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
config_output.http.services![bgServiceName] = {
|
||||
config_output.http.services![bgUiServiceName] = {
|
||||
loadBalancer: {
|
||||
servers
|
||||
servers: [
|
||||
{
|
||||
url: `http://${internalHost}:${internalPort}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// UI: serve the browser gateway page from the internal pangolin instance.
|
||||
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
|
||||
// how the maintenance page rewrites everything to /maintenance-screen.
|
||||
const primaryType = typeMap.keys().next().value as string;
|
||||
const internalHost = config.getRawConfig().server.internal_hostname;
|
||||
const internalPort = config.getRawConfig().server.next_port;
|
||||
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
|
||||
const entrypoint = bgResource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint;
|
||||
// Assets router at higher priority so /_next files load without rewrite
|
||||
config_output.http.routers![
|
||||
`bg-r${bgResource.resourceId}-assets-router`
|
||||
] = {
|
||||
entryPoints: [entrypoint],
|
||||
middlewares: routerMiddlewares,
|
||||
service: bgUiServiceName,
|
||||
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 101,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
|
||||
config_output.http.middlewares![uiRewriteMiddlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: "^/(.*)",
|
||||
replacement: `/${primaryType}`
|
||||
}
|
||||
};
|
||||
|
||||
config_output.http.services![bgUiServiceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `http://${internalHost}:${internalPort}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Assets router at higher priority so /_next files load without rewrite
|
||||
config_output.http.routers![
|
||||
`bg-r${bgResource.resourceId}-assets-router`
|
||||
] = {
|
||||
entryPoints: [entrypoint],
|
||||
middlewares: routerMiddlewares,
|
||||
service: bgUiServiceName,
|
||||
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 101,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
// Catch-all router rewrites everything on the domain to /{primaryType}
|
||||
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
|
||||
{
|
||||
// Catch-all router rewrites everything on the domain to /{primaryType}
|
||||
config_output.http.routers![
|
||||
`bg-r${bgResource.resourceId}-ui-router`
|
||||
] = {
|
||||
entryPoints: [entrypoint],
|
||||
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
|
||||
service: bgUiServiceName,
|
||||
@@ -1301,6 +1317,7 @@ export async function getTraefikConfig(
|
||||
priority: 100,
|
||||
...(bgResource.ssl ? { tls } : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||
|
||||
@@ -270,7 +270,8 @@ hybridRouter.get(
|
||||
true, // But don't allow domain namespace resources
|
||||
false, // Dont include login pages,
|
||||
true, // allow raw resources
|
||||
false // dont generate maintenance page
|
||||
false, // dont generate maintenance page
|
||||
false // dont generate browser gateway targets
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import { db } from "@server/db";
|
||||
import { orgs, resourcePassword, resources } from "@server/db";
|
||||
import { orgs, resourcePassword, resourcePolicies, resourcePolicyPassword, resources } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -61,17 +61,29 @@ export async function authWithPassword(
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.leftJoin(
|
||||
resourcePolicies,
|
||||
eq(resourcePolicies.resourcePolicyId, resources.resourcePolicyId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPassword,
|
||||
eq(resourcePolicyPassword.resourcePolicyId, resourcePolicies.resourcePolicyId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
const resource = result?.resources;
|
||||
const org = result?.orgs;
|
||||
const definedPassword = result?.resourcePassword;
|
||||
|
||||
// Policy password takes precedence over resource-level password
|
||||
const policyPassword = result?.resourcePolicyPassword ?? null;
|
||||
const definedPassword = policyPassword ?? result?.resourcePassword ?? null;
|
||||
const isPolicyPassword = !!policyPassword;
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
@@ -89,10 +101,7 @@ export async function authWithPassword(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Resource has no password protection"
|
||||
)
|
||||
"Resource has no password protection"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -126,7 +135,8 @@ export async function authWithPassword(
|
||||
await createResourceSession({
|
||||
resourceId,
|
||||
token,
|
||||
passwordId: definedPassword.passwordId,
|
||||
passwordId: isPolicyPassword ? null : definedPassword.passwordId,
|
||||
policyPasswordId: isPolicyPassword ? definedPassword.passwordId : null,
|
||||
isRequestToken: true,
|
||||
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||
sessionLength: 1000 * 30,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import { db } from "@server/db";
|
||||
import { orgs, resourcePincode, resources } from "@server/db";
|
||||
import { orgs, resourcePincode, resourcePolicies, resourcePolicyPincode, resources } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -60,17 +60,29 @@ export async function authWithPincode(
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.leftJoin(
|
||||
resourcePolicies,
|
||||
eq(resourcePolicies.resourcePolicyId, resources.resourcePolicyId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicies.resourcePolicyId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
const resource = result?.resources;
|
||||
const org = result?.orgs;
|
||||
const definedPincode = result?.resourcePincode;
|
||||
|
||||
// Policy pincode takes precedence over resource-level pincode
|
||||
const policyPincode = result?.resourcePolicyPincode ?? null;
|
||||
const definedPincode = policyPincode ?? result?.resourcePincode ?? null;
|
||||
const isPolicyPincode = !!policyPincode;
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
@@ -125,7 +137,8 @@ export async function authWithPincode(
|
||||
await createResourceSession({
|
||||
resourceId,
|
||||
token,
|
||||
pincodeId: definedPincode.pincodeId,
|
||||
pincodeId: isPolicyPincode ? null : definedPincode.pincodeId,
|
||||
policyPincodeId: isPolicyPincode ? definedPincode.pincodeId : null,
|
||||
isRequestToken: true,
|
||||
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||
sessionLength: 1000 * 30,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import { db } from "@server/db";
|
||||
import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db";
|
||||
import { orgs, resourceOtp, resources, resourceWhitelist, resourcePolicyWhiteList } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -59,82 +59,21 @@ export async function authWithWhitelist(
|
||||
const { email, otp } = parsedBody.data;
|
||||
|
||||
try {
|
||||
const [result] = await db
|
||||
// Fetch resource and org first
|
||||
const [resourceResult] = await db
|
||||
.select()
|
||||
.from(resourceWhitelist)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceWhitelist.resourceId, resourceId),
|
||||
eq(resourceWhitelist.email, email)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(resources.resourceId, resourceWhitelist.resourceId)
|
||||
)
|
||||
.from(resources)
|
||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
let resource = result?.resources;
|
||||
let org = result?.orgs;
|
||||
let whitelistedEmail = result?.resourceWhitelist;
|
||||
const resource = resourceResult?.resources;
|
||||
const org = resourceResult?.orgs;
|
||||
|
||||
if (!whitelistedEmail) {
|
||||
// if email is not found, check for wildcard email
|
||||
const wildcard = "*@" + email.split("@")[1];
|
||||
|
||||
logger.debug("Checking for wildcard email: " + wildcard);
|
||||
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(resourceWhitelist)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceWhitelist.resourceId, resourceId),
|
||||
eq(resourceWhitelist.email, wildcard)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(resources.resourceId, resourceWhitelist.resourceId)
|
||||
)
|
||||
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.limit(1);
|
||||
|
||||
resource = result?.resources;
|
||||
org = result?.orgs;
|
||||
whitelistedEmail = result?.resourceWhitelist;
|
||||
|
||||
// if wildcard is still not found, return unauthorized
|
||||
if (!whitelistedEmail) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (org && resource) {
|
||||
logAccessAudit({
|
||||
orgId: org.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: false,
|
||||
type: "whitelistedEmail",
|
||||
metadata: { email },
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Email is not whitelisted"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
|
||||
);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
@@ -143,9 +82,100 @@ export async function authWithWhitelist(
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
const wildcard = "*@" + email.split("@")[1];
|
||||
|
||||
// Check policy whitelist first (policy takes precedence over resource whitelist)
|
||||
let policyWhitelistEntry: { whitelistId: number; email: string } | null = null;
|
||||
if (resource.resourcePolicyId) {
|
||||
const [exact] = await db
|
||||
.select()
|
||||
.from(resourcePolicyWhiteList)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicyWhiteList.resourcePolicyId, resource.resourcePolicyId),
|
||||
eq(resourcePolicyWhiteList.email, email)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (exact) {
|
||||
policyWhitelistEntry = exact;
|
||||
} else {
|
||||
logger.debug("Checking for wildcard email in policy: " + wildcard);
|
||||
const [wildcardMatch] = await db
|
||||
.select()
|
||||
.from(resourcePolicyWhiteList)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicyWhiteList.resourcePolicyId, resource.resourcePolicyId),
|
||||
eq(resourcePolicyWhiteList.email, wildcard)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (wildcardMatch) policyWhitelistEntry = wildcardMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to resource whitelist if not found in policy
|
||||
let resourceWhitelistEntry: { whitelistId: number; email: string } | null = null;
|
||||
if (!policyWhitelistEntry) {
|
||||
const [exact] = await db
|
||||
.select()
|
||||
.from(resourceWhitelist)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceWhitelist.resourceId, resourceId),
|
||||
eq(resourceWhitelist.email, email)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (exact) {
|
||||
resourceWhitelistEntry = exact;
|
||||
} else {
|
||||
logger.debug("Checking for wildcard email: " + wildcard);
|
||||
const [wildcardMatch] = await db
|
||||
.select()
|
||||
.from(resourceWhitelist)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceWhitelist.resourceId, resourceId),
|
||||
eq(resourceWhitelist.email, wildcard)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (wildcardMatch) resourceWhitelistEntry = wildcardMatch;
|
||||
}
|
||||
}
|
||||
|
||||
const isPolicyWhitelist = !!policyWhitelistEntry;
|
||||
const whitelistedEmail = policyWhitelistEntry ?? resourceWhitelistEntry;
|
||||
|
||||
if (!whitelistedEmail) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
logAccessAudit({
|
||||
orgId: org.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: false,
|
||||
type: "whitelistedEmail",
|
||||
metadata: { email },
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Email is not whitelisted"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,7 +241,8 @@ export async function authWithWhitelist(
|
||||
await createResourceSession({
|
||||
resourceId,
|
||||
token,
|
||||
whitelistId: whitelistedEmail.whitelistId,
|
||||
whitelistId: isPolicyWhitelist ? null : whitelistedEmail.whitelistId,
|
||||
policyWhitelistId: isPolicyWhitelist ? whitelistedEmail.whitelistId : null,
|
||||
isRequestToken: true,
|
||||
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
||||
sessionLength: 1000 * 30,
|
||||
|
||||
@@ -5,11 +5,17 @@ import {
|
||||
resources,
|
||||
userResources,
|
||||
roleResources,
|
||||
userPolicies,
|
||||
rolePolicies,
|
||||
resourcePolicies,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourceWhitelist,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyWhiteList,
|
||||
siteResources,
|
||||
userSiteResources,
|
||||
roleSiteResources,
|
||||
@@ -27,6 +33,10 @@ export async function getUserResources(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const effectiveResourcePolicyId = sql<
|
||||
number | null
|
||||
>`coalesce(${resources.resourcePolicyId}, ${resources.defaultResourcePolicyId})`;
|
||||
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const userId = req.user?.userId;
|
||||
|
||||
@@ -80,6 +90,30 @@ export async function getUserResources(
|
||||
.where(inArray(roleResources.roleId, userRoleIds))
|
||||
: Promise.resolve([]);
|
||||
|
||||
const directPolicyResourcesQuery = db
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.innerJoin(
|
||||
userPolicies,
|
||||
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
|
||||
)
|
||||
.where(eq(userPolicies.userId, userId));
|
||||
|
||||
const rolePolicyResourcesQuery =
|
||||
userRoleIds.length > 0
|
||||
? db
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.innerJoin(
|
||||
rolePolicies,
|
||||
eq(
|
||||
effectiveResourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(inArray(rolePolicies.roleId, userRoleIds))
|
||||
: Promise.resolve([]);
|
||||
|
||||
const directSiteResourcesQuery = db
|
||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||
.from(userSiteResources)
|
||||
@@ -98,11 +132,15 @@ export async function getUserResources(
|
||||
const [
|
||||
directResources,
|
||||
roleResourceResults,
|
||||
directPolicyResourceResults,
|
||||
rolePolicyResourceResults,
|
||||
directSiteResourceResults,
|
||||
roleSiteResourceResults
|
||||
] = await Promise.all([
|
||||
directResourcesQuery,
|
||||
roleResourcesQuery,
|
||||
directPolicyResourcesQuery,
|
||||
rolePolicyResourcesQuery,
|
||||
directSiteResourcesQuery,
|
||||
roleSiteResourcesQuery
|
||||
]);
|
||||
@@ -110,18 +148,27 @@ export async function getUserResources(
|
||||
// Combine all accessible resource IDs
|
||||
const accessibleResourceIds = [
|
||||
...directResources.map((r) => r.resourceId),
|
||||
...roleResourceResults.map((r) => r.resourceId)
|
||||
...roleResourceResults.map((r) => r.resourceId),
|
||||
...directPolicyResourceResults.map((r) => r.resourceId),
|
||||
...rolePolicyResourceResults.map((r) => r.resourceId)
|
||||
];
|
||||
|
||||
// remove duplicates
|
||||
const uniqueResourceIds = Array.from(new Set(accessibleResourceIds));
|
||||
|
||||
// Combine all accessible site resource IDs
|
||||
const accessibleSiteResourceIds = [
|
||||
...directSiteResourceResults.map((r) => r.siteResourceId),
|
||||
...roleSiteResourceResults.map((r) => r.siteResourceId)
|
||||
];
|
||||
const uniqueSiteResourceIds = Array.from(
|
||||
new Set(accessibleSiteResourceIds)
|
||||
);
|
||||
|
||||
// Get resource details for accessible resources
|
||||
let resourcesData: Array<{
|
||||
resourceId: number;
|
||||
effectiveResourcePolicyId: number | null;
|
||||
name: string;
|
||||
fullDomain: string | null;
|
||||
ssl: boolean;
|
||||
@@ -129,23 +176,34 @@ export async function getUserResources(
|
||||
sso: boolean;
|
||||
mode: string;
|
||||
emailWhitelistEnabled: boolean;
|
||||
policyEmailWhitelistEnabled: boolean | null;
|
||||
}> = [];
|
||||
if (accessibleResourceIds.length > 0) {
|
||||
if (uniqueResourceIds.length > 0) {
|
||||
resourcesData = await db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
effectiveResourcePolicyId,
|
||||
name: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
enabled: resources.enabled,
|
||||
sso: resources.sso,
|
||||
mode: resources.mode,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
policyEmailWhitelistEnabled:
|
||||
resourcePolicies.emailWhitelistEnabled
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePolicies,
|
||||
eq(
|
||||
effectiveResourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
inArray(resources.resourceId, uniqueResourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true)
|
||||
)
|
||||
@@ -174,7 +232,7 @@ export async function getUserResources(
|
||||
siteAddresses: (string | null)[];
|
||||
siteOnlines: boolean[];
|
||||
}> = [];
|
||||
if (accessibleSiteResourceIds.length > 0) {
|
||||
if (uniqueSiteResourceIds.length > 0) {
|
||||
const aggCol = <T>(column: any) => {
|
||||
if (DB_TYPE === "sqlite") {
|
||||
return sql<T>`json_group_array(${column})`;
|
||||
@@ -214,7 +272,7 @@ export async function getUserResources(
|
||||
and(
|
||||
inArray(
|
||||
siteResources.siteResourceId,
|
||||
accessibleSiteResourceIds
|
||||
uniqueSiteResourceIds
|
||||
),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true)
|
||||
@@ -273,44 +331,87 @@ export async function getUserResources(
|
||||
// Check for password, pincode, and whitelist protection for each resource
|
||||
const resourcesWithAuth = await Promise.all(
|
||||
resourcesData.map(async (resource) => {
|
||||
const [passwordCheck, pincodeCheck, whitelistCheck] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourcePassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePassword.resourceId,
|
||||
resource.resourceId
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(resourcePincode)
|
||||
.where(
|
||||
eq(
|
||||
resourcePincode.resourceId,
|
||||
resource.resourceId
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(resourceWhitelist)
|
||||
.where(
|
||||
eq(
|
||||
resourceWhitelist.resourceId,
|
||||
resource.resourceId
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
const policyId = resource.effectiveResourcePolicyId;
|
||||
|
||||
const hasPassword = passwordCheck.length > 0;
|
||||
const hasPincode = pincodeCheck.length > 0;
|
||||
const [
|
||||
passwordCheck,
|
||||
pincodeCheck,
|
||||
whitelistCheck,
|
||||
policyPasswordCheck,
|
||||
policyPincodeCheck,
|
||||
policyWhitelistCheck
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourcePassword)
|
||||
.where(
|
||||
eq(resourcePassword.resourceId, resource.resourceId)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(resourcePincode)
|
||||
.where(
|
||||
eq(resourcePincode.resourceId, resource.resourceId)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(resourceWhitelist)
|
||||
.where(
|
||||
eq(
|
||||
resourceWhitelist.resourceId,
|
||||
resource.resourceId
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
policyId
|
||||
? db
|
||||
.select()
|
||||
.from(resourcePolicyPassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
policyId
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: Promise.resolve([]),
|
||||
policyId
|
||||
? db
|
||||
.select()
|
||||
.from(resourcePolicyPincode)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
policyId
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: Promise.resolve([]),
|
||||
policyId
|
||||
? db
|
||||
.select()
|
||||
.from(resourcePolicyWhiteList)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyWhiteList.resourcePolicyId,
|
||||
policyId
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const hasPassword =
|
||||
passwordCheck.length > 0 || policyPasswordCheck.length > 0;
|
||||
const hasPincode =
|
||||
pincodeCheck.length > 0 || policyPincodeCheck.length > 0;
|
||||
const hasWhitelist =
|
||||
whitelistCheck.length > 0 || resource.emailWhitelistEnabled;
|
||||
whitelistCheck.length > 0 ||
|
||||
policyWhitelistCheck.length > 0 ||
|
||||
resource.emailWhitelistEnabled ||
|
||||
!!resource.policyEmailWhitelistEnabled;
|
||||
|
||||
return {
|
||||
resourceId: resource.resourceId,
|
||||
@@ -386,7 +487,7 @@ export type GetUserResourcesResponse = {
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
protected: boolean;
|
||||
ode: string;
|
||||
mode: string;
|
||||
}>;
|
||||
siteResources: Array<{
|
||||
siteResourceId: number;
|
||||
|
||||
@@ -22,7 +22,8 @@ export async function traefikConfigProvider(
|
||||
config.getRawConfig().traefik.site_types,
|
||||
build == "oss", // filter out the namespace domains in open source
|
||||
build != "oss", // generate the login pages on the cloud and and enterprise,
|
||||
config.getRawConfig().traefik.allow_raw_resources
|
||||
config.getRawConfig().traefik.allow_raw_resources,
|
||||
build != "oss" // generate browser gateway resources on cloud and enterprise
|
||||
);
|
||||
|
||||
if (traefikConfig?.http?.middlewares) {
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
import { db } from "@server/db/pg/driver";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
|
||||
import { sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import path, { join } from "path";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const version = "1.19.0";
|
||||
|
||||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
let namesFile;
|
||||
if (!dev) {
|
||||
namesFile = join(__DIRNAME, "names.json");
|
||||
} else {
|
||||
namesFile = join("server/db/names.json");
|
||||
}
|
||||
export const names = JSON.parse(fs.readFileSync(namesFile, "utf-8"));
|
||||
|
||||
export function generateName(): string {
|
||||
const name = (
|
||||
names.descriptors[
|
||||
Math.floor(Math.random() * names.descriptors.length)
|
||||
] +
|
||||
"-" +
|
||||
names.animals[Math.floor(Math.random() * names.animals.length)]
|
||||
)
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-");
|
||||
|
||||
// Clean out non-alphanumeric characters except dashes.
|
||||
return name.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
@@ -164,6 +188,15 @@ export default async function migration() {
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "mode" text DEFAULT 'http' NOT NULL;`
|
||||
);
|
||||
await db.execute(sql`
|
||||
UPDATE "resources"
|
||||
SET "mode" = CASE
|
||||
WHEN COALESCE("http", true) = true THEN 'http'
|
||||
WHEN COALESCE("http", false) = false AND LOWER(COALESCE("protocol", '')) = 'tcp' THEN 'tcp'
|
||||
WHEN COALESCE("http", false) = false AND LOWER(COALESCE("protocol", '')) = 'udp' THEN 'udp'
|
||||
ELSE 'http'
|
||||
END;
|
||||
`);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "pamMode" varchar(32) DEFAULT 'passthrough';`
|
||||
);
|
||||
@@ -266,6 +299,325 @@ export default async function migration() {
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingResourcesQuery = await db.execute(sql`
|
||||
SELECT
|
||||
"resourceId",
|
||||
"orgId",
|
||||
"niceId",
|
||||
COALESCE("sso", true) AS "sso",
|
||||
COALESCE("applyRules", false) AS "applyRules",
|
||||
COALESCE("emailWhitelistEnabled", false) AS "emailWhitelistEnabled",
|
||||
"skipToIdpId"
|
||||
FROM "resources"
|
||||
`);
|
||||
const existingResources = existingResourcesQuery.rows as {
|
||||
resourceId: number;
|
||||
orgId: string;
|
||||
niceId: string;
|
||||
sso: boolean;
|
||||
applyRules: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
skipToIdpId: number | null;
|
||||
}[];
|
||||
|
||||
if (existingResources.length > 0) {
|
||||
const usedPolicyNiceIds = new Set<string>();
|
||||
|
||||
await db.execute(sql`BEGIN`);
|
||||
try {
|
||||
for (const resource of existingResources) {
|
||||
let policyNiceId = "";
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error(
|
||||
`Could not generate a unique policy name for resource ${resource.resourceId}`
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = generateName();
|
||||
const existingPolicy = await db.execute(sql`
|
||||
SELECT 1
|
||||
FROM "resourcePolicies"
|
||||
WHERE "orgId" = ${resource.orgId}
|
||||
AND "niceId" = ${candidate}
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (
|
||||
!usedPolicyNiceIds.has(candidate) &&
|
||||
existingPolicy.rows.length === 0
|
||||
) {
|
||||
usedPolicyNiceIds.add(candidate);
|
||||
policyNiceId = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
loops++;
|
||||
}
|
||||
|
||||
const policyName = `default policy for ${resource.niceId}`;
|
||||
|
||||
const insertedPolicy = await db.execute(sql`
|
||||
INSERT INTO "resourcePolicies" (
|
||||
"sso",
|
||||
"applyRules",
|
||||
"scope",
|
||||
"emailWhitelistEnabled",
|
||||
"niceId",
|
||||
"idpId",
|
||||
"name",
|
||||
"orgId"
|
||||
) VALUES (
|
||||
${resource.sso},
|
||||
${resource.applyRules},
|
||||
'resource',
|
||||
${resource.emailWhitelistEnabled},
|
||||
${policyNiceId},
|
||||
${resource.skipToIdpId},
|
||||
${policyName},
|
||||
${resource.orgId}
|
||||
)
|
||||
RETURNING "resourcePolicyId"
|
||||
`);
|
||||
const resourcePolicyId = (
|
||||
insertedPolicy.rows[0] as { resourcePolicyId: number }
|
||||
).resourcePolicyId;
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE "resources"
|
||||
SET
|
||||
"defaultResourcePolicyId" = ${resourcePolicyId}
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
|
||||
const existingPincodes = await db.execute(sql`
|
||||
SELECT "pincodeHash", "digitLength"
|
||||
FROM "resourcePincode"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
for (const pincode of existingPincodes.rows as {
|
||||
pincodeHash: string;
|
||||
digitLength: number;
|
||||
}[]) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "resourcePolicyPincode" (
|
||||
"pincodeHash",
|
||||
"digitLength",
|
||||
"resourcePolicyId"
|
||||
) VALUES (
|
||||
${pincode.pincodeHash},
|
||||
${pincode.digitLength},
|
||||
${resourcePolicyId}
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
const existingPasswords = await db.execute(sql`
|
||||
SELECT "passwordHash"
|
||||
FROM "resourcePassword"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
for (const password of existingPasswords.rows as {
|
||||
passwordHash: string;
|
||||
}[]) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "resourcePolicyPassword" (
|
||||
"passwordHash",
|
||||
"resourcePolicyId"
|
||||
) VALUES (
|
||||
${password.passwordHash},
|
||||
${resourcePolicyId}
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
const headerCompatibilityQuery = await db.execute(sql`
|
||||
SELECT COALESCE("extendedCompatibilityIsActivated", true) AS "extendedCompatibility"
|
||||
FROM "resourceHeaderAuthExtendedCompatibility"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
LIMIT 1
|
||||
`);
|
||||
const extendedCompatibility =
|
||||
headerCompatibilityQuery.rows.length > 0
|
||||
? (
|
||||
headerCompatibilityQuery.rows[0] as {
|
||||
extendedCompatibility: boolean;
|
||||
}
|
||||
).extendedCompatibility
|
||||
: true;
|
||||
|
||||
const existingHeaderAuth = await db.execute(sql`
|
||||
SELECT "headerAuthHash"
|
||||
FROM "resourceHeaderAuth"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
for (const headerAuth of existingHeaderAuth.rows as {
|
||||
headerAuthHash: string;
|
||||
}[]) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "resourcePolicyHeaderAuth" (
|
||||
"headerAuthHash",
|
||||
"extendedCompatibility",
|
||||
"resourcePolicyId"
|
||||
) VALUES (
|
||||
${headerAuth.headerAuthHash},
|
||||
${extendedCompatibility},
|
||||
${resourcePolicyId}
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
const existingRules = await db.execute(sql`
|
||||
SELECT "enabled", "priority", "action", "match", "value"
|
||||
FROM "resourceRules"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
for (const rule of existingRules.rows as {
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
action: string;
|
||||
match: string;
|
||||
value: string;
|
||||
}[]) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "resourcePolicyRules" (
|
||||
"resourcePolicyId",
|
||||
"enabled",
|
||||
"priority",
|
||||
"action",
|
||||
"match",
|
||||
"value"
|
||||
) VALUES (
|
||||
${resourcePolicyId},
|
||||
${rule.enabled},
|
||||
${rule.priority},
|
||||
${rule.action},
|
||||
${rule.match},
|
||||
${rule.value}
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
const existingWhitelist = await db.execute(sql`
|
||||
SELECT "email"
|
||||
FROM "resourceWhitelist"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
for (const whitelistRow of existingWhitelist.rows as {
|
||||
email: string;
|
||||
}[]) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "resourcePolicyWhitelist" (
|
||||
"email",
|
||||
"resourcePolicyId"
|
||||
) VALUES (
|
||||
${whitelistRow.email},
|
||||
${resourcePolicyId}
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
const existingRoleResources = await db.execute(sql`
|
||||
SELECT "roleId"
|
||||
FROM "roleResources"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
for (const roleRow of existingRoleResources.rows as {
|
||||
roleId: number;
|
||||
}[]) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "rolePolicies" ("roleId", "resourcePolicyId")
|
||||
SELECT ${roleRow.roleId}, ${resourcePolicyId}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "rolePolicies"
|
||||
WHERE "roleId" = ${roleRow.roleId}
|
||||
AND "resourcePolicyId" = ${resourcePolicyId}
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
const existingUserResources = await db.execute(sql`
|
||||
SELECT "userId"
|
||||
FROM "userResources"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
for (const userRow of existingUserResources.rows as {
|
||||
userId: string;
|
||||
}[]) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userPolicies" ("userId", "resourcePolicyId")
|
||||
SELECT ${userRow.userId}, ${resourcePolicyId}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "userPolicies"
|
||||
WHERE "userId" = ${userRow.userId}
|
||||
AND "resourcePolicyId" = ${resourcePolicyId}
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
await db.execute(sql`
|
||||
DELETE FROM "resourcePincode"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DELETE FROM "resourcePassword"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DELETE FROM "resourceHeaderAuth"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DELETE FROM "resourceHeaderAuthExtendedCompatibility"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DELETE FROM "resourceRules"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DELETE FROM "resourceWhitelist"
|
||||
WHERE "resourceId" = ${resource.resourceId}
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resourceSessions" ADD COLUMN "policyPasswordId" integer;
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resourceSessions" ADD COLUMN "policyPincodeId" integer;
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resourceSessions" ADD COLUMN "policyWhitelistId" integer;
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPasswordId_resourcePolicyPassword_passwordId_fk" FOREIGN KEY ("policyPasswordId") REFERENCES "public"."resourcePolicyPassword"("passwordId") ON DELETE cascade ON UPDATE no action;
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPincodeId_resourcePolicyPincode_pincodeId_fk" FOREIGN KEY ("policyPincodeId") REFERENCES "public"."resourcePolicyPincode"("pincodeId") ON DELETE cascade ON UPDATE no action;
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyWhitelistId_resourcePolicyWhitelist_id_fk" FOREIGN KEY ("policyWhitelistId") REFERENCES "public"."resourcePolicyWhitelist"("id") ON DELETE cascade ON UPDATE no action;
|
||||
`);
|
||||
}
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log(
|
||||
`Migrated inline resource policies for ${existingResources.length} resource(s)`
|
||||
);
|
||||
} catch (e) {
|
||||
await db.execute(sql`ROLLBACK`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Unable to migrate inline resource policies");
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const traefikPath = path.join(
|
||||
APP_PATH,
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import z from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import path, { join } from "path";
|
||||
|
||||
const version = "1.19.0";
|
||||
|
||||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
let namesFile;
|
||||
if (!dev) {
|
||||
namesFile = join(__DIRNAME, "names.json");
|
||||
} else {
|
||||
namesFile = join("server/db/names.json");
|
||||
}
|
||||
export const names = JSON.parse(fs.readFileSync(namesFile, "utf-8"));
|
||||
|
||||
export function generateName(): string {
|
||||
const name = (
|
||||
names.descriptors[
|
||||
Math.floor(Math.random() * names.descriptors.length)
|
||||
] +
|
||||
"-" +
|
||||
names.animals[Math.floor(Math.random() * names.animals.length)]
|
||||
)
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-");
|
||||
|
||||
// Clean out non-alphanumeric characters except dashes.
|
||||
return name.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
|
||||
await migration();
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
@@ -265,6 +291,17 @@ export default async function migration() {
|
||||
ALTER TABLE 'resources' ADD 'mode' text DEFAULT 'http' NOT NULL;
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE 'resources'
|
||||
SET "mode" = CASE
|
||||
WHEN COALESCE("http", 1) = 1 THEN 'http'
|
||||
WHEN COALESCE("http", 0) = 0 AND LOWER(COALESCE("protocol", '')) = 'tcp' THEN 'tcp'
|
||||
WHEN COALESCE("http", 0) = 0 AND LOWER(COALESCE("protocol", '')) = 'udp' THEN 'udp'
|
||||
ELSE 'http'
|
||||
END;
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'resources' ADD 'pamMode' text DEFAULT 'passthrough';
|
||||
@@ -300,8 +337,349 @@ export default async function migration() {
|
||||
ALTER TABLE 'sites' ADD 'autoUpdateOverrideOrg' integer DEFAULT false NOT NULL;
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'resourceSessions' ADD 'policyPasswordId' integer REFERENCES resourcePolicyPassword(passwordId);
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'resourceSessions' ADD 'policyPincodeId' integer REFERENCES resourcePolicyPincode(pincodeId);
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
|
||||
`
|
||||
).run();
|
||||
})();
|
||||
|
||||
const existingResources = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
"resourceId",
|
||||
"orgId",
|
||||
"niceId",
|
||||
COALESCE("sso", 1) AS "sso",
|
||||
COALESCE("applyRules", 0) AS "applyRules",
|
||||
COALESCE("emailWhitelistEnabled", 0) AS "emailWhitelistEnabled",
|
||||
"skipToIdpId"
|
||||
FROM 'resources'`
|
||||
)
|
||||
.all() as {
|
||||
resourceId: number;
|
||||
orgId: string;
|
||||
niceId: string;
|
||||
sso: number;
|
||||
applyRules: number;
|
||||
emailWhitelistEnabled: number;
|
||||
skipToIdpId: number | null;
|
||||
}[];
|
||||
|
||||
if (existingResources.length > 0) {
|
||||
const insertResourcePolicy = db.prepare(
|
||||
`INSERT INTO 'resourcePolicies' (
|
||||
"sso",
|
||||
"applyRules",
|
||||
"scope",
|
||||
"emailWhitelistEnabled",
|
||||
"niceId",
|
||||
"idpId",
|
||||
"name",
|
||||
"orgId"
|
||||
) VALUES (?, ?, 'resource', ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const updateResourcePolicyRefs = db.prepare(
|
||||
`UPDATE 'resources'
|
||||
SET "defaultResourcePolicyId" = ?
|
||||
WHERE "resourceId" = ?`
|
||||
);
|
||||
const policyNiceIdExists = db.prepare(
|
||||
`SELECT 1
|
||||
FROM 'resourcePolicies'
|
||||
WHERE "niceId" = ? AND "orgId" = ?
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
const selectResourcePincodes = db.prepare(
|
||||
`SELECT "pincodeHash", "digitLength"
|
||||
FROM 'resourcePincode'
|
||||
WHERE "resourceId" = ?`
|
||||
);
|
||||
const insertResourcePolicyPincode = db.prepare(
|
||||
`INSERT INTO 'resourcePolicyPincode' (
|
||||
"pincodeHash",
|
||||
"digitLength",
|
||||
"resourcePolicyId"
|
||||
) VALUES (?, ?, ?)`
|
||||
);
|
||||
|
||||
const selectResourcePasswords = db.prepare(
|
||||
`SELECT "passwordHash"
|
||||
FROM 'resourcePassword'
|
||||
WHERE "resourceId" = ?`
|
||||
);
|
||||
const insertResourcePolicyPassword = db.prepare(
|
||||
`INSERT INTO 'resourcePolicyPassword' (
|
||||
"passwordHash",
|
||||
"resourcePolicyId"
|
||||
) VALUES (?, ?)`
|
||||
);
|
||||
|
||||
const selectResourceHeaderAuth = db.prepare(
|
||||
`SELECT "headerAuthHash"
|
||||
FROM 'resourceHeaderAuth'
|
||||
WHERE "resourceId" = ?`
|
||||
);
|
||||
const selectResourceHeaderCompatibility = db.prepare(
|
||||
`SELECT COALESCE("extendedCompatibilityIsActivated", 1) AS "extendedCompatibility"
|
||||
FROM 'resourceHeaderAuthExtendedCompatibility'
|
||||
WHERE "resourceId" = ?
|
||||
LIMIT 1`
|
||||
);
|
||||
const insertResourcePolicyHeaderAuth = db.prepare(
|
||||
`INSERT INTO 'resourcePolicyHeaderAuth' (
|
||||
"headerAuthHash",
|
||||
"extendedCompatibility",
|
||||
"resourcePolicyId"
|
||||
) VALUES (?, ?, ?)`
|
||||
);
|
||||
|
||||
const selectResourceRules = db.prepare(
|
||||
`SELECT "enabled", "priority", "action", "match", "value"
|
||||
FROM 'resourceRules'
|
||||
WHERE "resourceId" = ?`
|
||||
);
|
||||
const insertResourcePolicyRule = db.prepare(
|
||||
`INSERT INTO 'resourcePolicyRules' (
|
||||
"resourcePolicyId",
|
||||
"enabled",
|
||||
"priority",
|
||||
"action",
|
||||
"match",
|
||||
"value"
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
const selectResourceWhitelist = db.prepare(
|
||||
`SELECT "email"
|
||||
FROM 'resourceWhitelist'
|
||||
WHERE "resourceId" = ?`
|
||||
);
|
||||
const insertResourcePolicyWhitelist = db.prepare(
|
||||
`INSERT INTO 'resourcePolicyWhitelist' (
|
||||
"email",
|
||||
"resourcePolicyId"
|
||||
) VALUES (?, ?)`
|
||||
);
|
||||
|
||||
const selectRoleResources = db.prepare(
|
||||
`SELECT "roleId"
|
||||
FROM 'roleResources'
|
||||
WHERE "resourceId" = ?`
|
||||
);
|
||||
const rolePolicyExists = db.prepare(
|
||||
`SELECT 1
|
||||
FROM 'rolePolicies'
|
||||
WHERE "roleId" = ? AND "resourcePolicyId" = ?
|
||||
LIMIT 1`
|
||||
);
|
||||
const insertRolePolicy = db.prepare(
|
||||
`INSERT INTO 'rolePolicies' (
|
||||
"roleId",
|
||||
"resourcePolicyId"
|
||||
) VALUES (?, ?)`
|
||||
);
|
||||
|
||||
const selectUserResources = db.prepare(
|
||||
`SELECT "userId"
|
||||
FROM 'userResources'
|
||||
WHERE "resourceId" = ?`
|
||||
);
|
||||
const userPolicyExists = db.prepare(
|
||||
`SELECT 1
|
||||
FROM 'userPolicies'
|
||||
WHERE "userId" = ? AND "resourcePolicyId" = ?
|
||||
LIMIT 1`
|
||||
);
|
||||
const insertUserPolicy = db.prepare(
|
||||
`INSERT INTO 'userPolicies' (
|
||||
"userId",
|
||||
"resourcePolicyId"
|
||||
) VALUES (?, ?)`
|
||||
);
|
||||
|
||||
const deleteResourcePincodes = db.prepare(
|
||||
`DELETE FROM 'resourcePincode' WHERE "resourceId" = ?`
|
||||
);
|
||||
const deleteResourcePasswords = db.prepare(
|
||||
`DELETE FROM 'resourcePassword' WHERE "resourceId" = ?`
|
||||
);
|
||||
const deleteResourceHeaderAuth = db.prepare(
|
||||
`DELETE FROM 'resourceHeaderAuth' WHERE "resourceId" = ?`
|
||||
);
|
||||
const deleteResourceHeaderCompatibility = db.prepare(
|
||||
`DELETE FROM 'resourceHeaderAuthExtendedCompatibility' WHERE "resourceId" = ?`
|
||||
);
|
||||
const deleteResourceRules = db.prepare(
|
||||
`DELETE FROM 'resourceRules' WHERE "resourceId" = ?`
|
||||
);
|
||||
const deleteResourceWhitelist = db.prepare(
|
||||
`DELETE FROM 'resourceWhitelist' WHERE "resourceId" = ?`
|
||||
);
|
||||
|
||||
const usedPolicyNiceIds = new Set<string>();
|
||||
|
||||
const migrateInlinePolicies = db.transaction(() => {
|
||||
for (const resource of existingResources) {
|
||||
let policyNiceId = "";
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error(
|
||||
`Could not generate a unique policy name for resource ${resource.resourceId}`
|
||||
);
|
||||
}
|
||||
|
||||
const candidate = generateName();
|
||||
const exists = policyNiceIdExists.get(
|
||||
candidate,
|
||||
resource.orgId
|
||||
) as { 1: number } | undefined;
|
||||
if (!usedPolicyNiceIds.has(candidate) && !exists) {
|
||||
usedPolicyNiceIds.add(candidate);
|
||||
policyNiceId = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
loops++;
|
||||
}
|
||||
|
||||
const policyName = `default policy for ${resource.niceId}`;
|
||||
|
||||
const inserted = insertResourcePolicy.run(
|
||||
resource.sso,
|
||||
resource.applyRules,
|
||||
resource.emailWhitelistEnabled,
|
||||
policyNiceId,
|
||||
resource.skipToIdpId,
|
||||
policyName,
|
||||
resource.orgId
|
||||
);
|
||||
const policyId = inserted.lastInsertRowid as number;
|
||||
|
||||
updateResourcePolicyRefs.run(policyId, resource.resourceId);
|
||||
|
||||
const resourcePincodes = selectResourcePincodes.all(
|
||||
resource.resourceId
|
||||
) as { pincodeHash: string; digitLength: number }[];
|
||||
for (const pincode of resourcePincodes) {
|
||||
insertResourcePolicyPincode.run(
|
||||
pincode.pincodeHash,
|
||||
pincode.digitLength,
|
||||
policyId
|
||||
);
|
||||
}
|
||||
|
||||
const resourcePasswords = selectResourcePasswords.all(
|
||||
resource.resourceId
|
||||
) as { passwordHash: string }[];
|
||||
for (const password of resourcePasswords) {
|
||||
insertResourcePolicyPassword.run(
|
||||
password.passwordHash,
|
||||
policyId
|
||||
);
|
||||
}
|
||||
|
||||
const compatibilityRow =
|
||||
selectResourceHeaderCompatibility.get(
|
||||
resource.resourceId
|
||||
) as { extendedCompatibility: number } | undefined;
|
||||
const extendedCompatibility =
|
||||
compatibilityRow?.extendedCompatibility ?? 1;
|
||||
|
||||
const resourceHeaderAuthRows = selectResourceHeaderAuth.all(
|
||||
resource.resourceId
|
||||
) as { headerAuthHash: string }[];
|
||||
for (const headerAuth of resourceHeaderAuthRows) {
|
||||
insertResourcePolicyHeaderAuth.run(
|
||||
headerAuth.headerAuthHash,
|
||||
extendedCompatibility,
|
||||
policyId
|
||||
);
|
||||
}
|
||||
|
||||
const resourceRules = selectResourceRules.all(
|
||||
resource.resourceId
|
||||
) as {
|
||||
enabled: number;
|
||||
priority: number;
|
||||
action: string;
|
||||
match: string;
|
||||
value: string;
|
||||
}[];
|
||||
for (const rule of resourceRules) {
|
||||
insertResourcePolicyRule.run(
|
||||
policyId,
|
||||
rule.enabled,
|
||||
rule.priority,
|
||||
rule.action,
|
||||
rule.match,
|
||||
rule.value
|
||||
);
|
||||
}
|
||||
|
||||
const resourceWhitelist = selectResourceWhitelist.all(
|
||||
resource.resourceId
|
||||
) as { email: string }[];
|
||||
for (const whitelistRow of resourceWhitelist) {
|
||||
insertResourcePolicyWhitelist.run(
|
||||
whitelistRow.email,
|
||||
policyId
|
||||
);
|
||||
}
|
||||
|
||||
const resourceRoles = selectRoleResources.all(
|
||||
resource.resourceId
|
||||
) as { roleId: number }[];
|
||||
for (const role of resourceRoles) {
|
||||
const exists = rolePolicyExists.get(
|
||||
role.roleId,
|
||||
policyId
|
||||
) as { 1: number } | undefined;
|
||||
if (!exists) {
|
||||
insertRolePolicy.run(role.roleId, policyId);
|
||||
}
|
||||
}
|
||||
|
||||
const resourceUsers = selectUserResources.all(
|
||||
resource.resourceId
|
||||
) as { userId: string }[];
|
||||
for (const user of resourceUsers) {
|
||||
const exists = userPolicyExists.get(
|
||||
user.userId,
|
||||
policyId
|
||||
) as { 1: number } | undefined;
|
||||
if (!exists) {
|
||||
insertUserPolicy.run(user.userId, policyId);
|
||||
}
|
||||
}
|
||||
|
||||
deleteResourcePincodes.run(resource.resourceId);
|
||||
deleteResourcePasswords.run(resource.resourceId);
|
||||
deleteResourceHeaderAuth.run(resource.resourceId);
|
||||
deleteResourceHeaderCompatibility.run(resource.resourceId);
|
||||
deleteResourceRules.run(resource.resourceId);
|
||||
deleteResourceWhitelist.run(resource.resourceId);
|
||||
}
|
||||
});
|
||||
|
||||
migrateInlinePolicies();
|
||||
console.log(
|
||||
`Migrated inline resource policies for ${existingResources.length} resource(s)`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
|
||||
@@ -315,13 +315,13 @@ export default function ResourceAuthenticationPage() {
|
||||
key={policies.sharedPolicy.resourcePolicyId}
|
||||
>
|
||||
<ActionBanner
|
||||
variant="warning"
|
||||
title={t("resourcePolicyReadOnly")}
|
||||
variant="info"
|
||||
title={t("resourcePolicyShared")}
|
||||
titleIcon={
|
||||
<ShieldAlertIcon className="w-5 h-5" />
|
||||
}
|
||||
description={t(
|
||||
"resourcePolicyReadOnlyDescription"
|
||||
"resourcePolicySharedDescription"
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
@@ -332,14 +332,13 @@ export default function ResourceAuthenticationPage() {
|
||||
<Link
|
||||
href={`/${org.org.orgId}/settings/policies/resource/${policies.sharedPolicy.niceId}`}
|
||||
>
|
||||
{t("edit")}
|
||||
{t("editSharedPolicy")}
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<EditPolicyForm
|
||||
readonly
|
||||
resourceId={resource.resourceId}
|
||||
/>
|
||||
</ResourcePolicyProvider>
|
||||
|
||||
@@ -101,12 +101,6 @@ export default function ReverseProxyTargetsPage(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{resource.mode == "tcp" && (
|
||||
<ProxyResourceProtocolForm
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -405,205 +399,4 @@ function ProxyResourceHttpForm({
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyResourceProtocolForm({
|
||||
resource,
|
||||
updateResource
|
||||
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const proxySettingsSchema = z.object({
|
||||
setHostHeader: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("proxyErrorInvalidHeader")
|
||||
}
|
||||
),
|
||||
headers: z
|
||||
.array(z.object({ name: z.string(), value: z.string() }))
|
||||
.nullable(),
|
||||
proxyProtocol: z.boolean().optional(),
|
||||
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
||||
});
|
||||
|
||||
const proxySettingsForm = useForm({
|
||||
resolver: zodResolver(proxySettingsSchema),
|
||||
defaultValues: {
|
||||
setHostHeader: resource.setHostHeader || "",
|
||||
headers: resource.headers,
|
||||
proxyProtocol: resource.proxyProtocol || false,
|
||||
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(
|
||||
saveProtocolSettings,
|
||||
null
|
||||
);
|
||||
|
||||
async function saveProtocolSettings() {
|
||||
const isValid = proxySettingsForm.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
try {
|
||||
// For TCP/UDP resources, save proxy protocol settings
|
||||
const proxyData = proxySettingsForm.getValues();
|
||||
|
||||
const payload = {
|
||||
proxyProtocol: proxyData.proxyProtocol || false,
|
||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||
};
|
||||
|
||||
await api.post(`/resource/${resource.resourceId}`, payload);
|
||||
|
||||
updateResource({
|
||||
...resource,
|
||||
proxyProtocol: proxyData.proxyProtocol || false,
|
||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("proxyProtocol")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("proxyProtocolDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...proxySettingsForm}>
|
||||
<form
|
||||
action={formAction}
|
||||
className="space-y-4"
|
||||
id="proxy-protocol-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={proxySettingsForm.control}
|
||||
name="proxyProtocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="proxy-protocol-toggle"
|
||||
label={t("enableProxyProtocol")}
|
||||
description={t(
|
||||
"proxyProtocolInfo"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value || false
|
||||
}
|
||||
onCheckedChange={(val) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{proxySettingsForm.watch("proxyProtocol") && (
|
||||
<>
|
||||
<FormField
|
||||
control={proxySettingsForm.control}
|
||||
name="proxyProtocolVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("proxyProtocolVersion")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={String(
|
||||
field.value || 1
|
||||
)}
|
||||
onValueChange={(
|
||||
value
|
||||
) =>
|
||||
field.onChange(
|
||||
parseInt(
|
||||
value,
|
||||
10
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">
|
||||
{t("version1")}
|
||||
</SelectItem>
|
||||
<SelectItem value="2">
|
||||
{t("version2")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("versionDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>{t("warning")}:</strong>{" "}
|
||||
{t("proxyProtocolWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
<form action={formAction} className="flex justify-end">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveProxyProtocol")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -96,10 +96,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/authentication`
|
||||
});
|
||||
// navItems.push({
|
||||
// title: t("rules"),
|
||||
// href: `/{orgId}/settings/resources/public/{niceId}/rules`
|
||||
// });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tlsNameSchema } from "@server/lib/schemas";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ProxyResourceTargetsForm
|
||||
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
|
||||
import {
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
use,
|
||||
useActionState,
|
||||
} from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function ReverseProxyTargetsPage(props: {
|
||||
params: Promise<{ resourceId: number; orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
||||
resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoadingTargets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<ProxyResourceTargetsForm
|
||||
orgId={params.orgId}
|
||||
isHttp={["http", "ssh", "rdp", "vnc"].includes(resource.mode)}
|
||||
initialTargets={remoteTargets}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
|
||||
{resource.mode == "tcp" && (
|
||||
<ProxyResourceProtocolForm
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyResourceProtocolForm({
|
||||
resource,
|
||||
updateResource
|
||||
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const proxySettingsSchema = z.object({
|
||||
setHostHeader: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("proxyErrorInvalidHeader")
|
||||
}
|
||||
),
|
||||
headers: z
|
||||
.array(z.object({ name: z.string(), value: z.string() }))
|
||||
.nullable(),
|
||||
proxyProtocol: z.boolean().optional(),
|
||||
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
||||
});
|
||||
|
||||
const proxySettingsForm = useForm({
|
||||
resolver: zodResolver(proxySettingsSchema),
|
||||
defaultValues: {
|
||||
setHostHeader: resource.setHostHeader || "",
|
||||
headers: resource.headers,
|
||||
proxyProtocol: resource.proxyProtocol || false,
|
||||
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(
|
||||
saveProtocolSettings,
|
||||
null
|
||||
);
|
||||
|
||||
async function saveProtocolSettings() {
|
||||
const isValid = proxySettingsForm.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
try {
|
||||
// For TCP/UDP resources, save proxy protocol settings
|
||||
const proxyData = proxySettingsForm.getValues();
|
||||
|
||||
const payload = {
|
||||
proxyProtocol: proxyData.proxyProtocol || false,
|
||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||
};
|
||||
|
||||
await api.post(`/resource/${resource.resourceId}`, payload);
|
||||
|
||||
updateResource({
|
||||
...resource,
|
||||
proxyProtocol: proxyData.proxyProtocol || false,
|
||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("proxyProtocol")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("proxyProtocolDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...proxySettingsForm}>
|
||||
<form
|
||||
action={formAction}
|
||||
className="space-y-4"
|
||||
id="proxy-protocol-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={proxySettingsForm.control}
|
||||
name="proxyProtocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="proxy-protocol-toggle"
|
||||
label={t("enableProxyProtocol")}
|
||||
description={t(
|
||||
"proxyProtocolInfo"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value || false
|
||||
}
|
||||
onCheckedChange={(val) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{proxySettingsForm.watch("proxyProtocol") && (
|
||||
<>
|
||||
<FormField
|
||||
control={proxySettingsForm.control}
|
||||
name="proxyProtocolVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("proxyProtocolVersion")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={String(
|
||||
field.value || 1
|
||||
)}
|
||||
onValueChange={(
|
||||
value
|
||||
) =>
|
||||
field.onChange(
|
||||
parseInt(
|
||||
value,
|
||||
10
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">
|
||||
{t("version1")}
|
||||
</SelectItem>
|
||||
<SelectItem value="2">
|
||||
{t("version2")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("versionDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>{t("warning")}:</strong>{" "}
|
||||
{t("proxyProtocolWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
<form action={formAction} className="flex justify-end">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveProxyProtocol")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
} from "@app/components/Settings";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ProxyResourceTargetsForm
|
||||
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
|
||||
import {
|
||||
use,
|
||||
} from "react";
|
||||
|
||||
export default function ReverseProxyTargetsPage(props: {
|
||||
params: Promise<{ resourceId: number; orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
||||
resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoadingTargets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<ProxyResourceTargetsForm
|
||||
orgId={params.orgId}
|
||||
isHttp={["http", "ssh", "rdp", "vnc"].includes(resource.mode)}
|
||||
initialTargets={remoteTargets}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,11 @@ export function EditPolicyForm({
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// In overlay mode (resourceId provided), policy-level sections are locked.
|
||||
// Rules and users/roles sections handle their own hybrid logic via resourceId.
|
||||
const isOverlay = resourceId !== undefined;
|
||||
const policyLevelReadonly = readonly || isOverlay;
|
||||
|
||||
const isMaxmindAvailable = !!(
|
||||
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0
|
||||
);
|
||||
@@ -79,7 +84,7 @@ export function EditPolicyForm({
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{!hidePolicyNameForm && (
|
||||
<EditPolicyNameSectionForm readonly={readonly} />
|
||||
<EditPolicyNameSectionForm readonly={policyLevelReadonly} />
|
||||
)}
|
||||
|
||||
<EditPolicyUsersRolesSectionForm
|
||||
@@ -89,11 +94,11 @@ export function EditPolicyForm({
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
|
||||
<EditPolicyAuthMethodsSectionForm readonly={readonly} />
|
||||
<EditPolicyAuthMethodsSectionForm readonly={policyLevelReadonly} />
|
||||
|
||||
<EditPolicyOtpEmailSectionForm
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
readonly={readonly}
|
||||
readonly={policyLevelReadonly}
|
||||
/>
|
||||
|
||||
<EditPolicyRulesSectionForm
|
||||
|
||||
@@ -171,7 +171,7 @@ export function EditPolicyRulesSectionForm({
|
||||
});
|
||||
|
||||
const [rules, setRules] = useState<LocalRule[]>(
|
||||
policy.rules.map((r) => ({ ...r, fromPolicy: !isResourceOverlay }))
|
||||
policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay }))
|
||||
);
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
rulesEnabled || isResourceOverlay
|
||||
@@ -196,8 +196,8 @@ export function EditPolicyRulesSectionForm({
|
||||
}));
|
||||
|
||||
setRules([
|
||||
...policy.rules.map((r) => ({ ...r, fromPolicy: true })),
|
||||
...resourceSpecific
|
||||
...resourceSpecific,
|
||||
...policy.rules.map((r) => ({ ...r, fromPolicy: true }))
|
||||
]);
|
||||
setResourceRulesInitialized(true);
|
||||
}, [
|
||||
|
||||
@@ -23,7 +23,10 @@ import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPolicySchema } from ".";
|
||||
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import {
|
||||
RolesSelector,
|
||||
type SelectedRole
|
||||
} from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
@@ -59,6 +62,8 @@ type PolicyUsersRolesSectionProps = {
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
type OverlaySelectedRole = SelectedRole & { isAdmin: boolean };
|
||||
|
||||
export function EditPolicyUsersRolesSectionForm({
|
||||
orgId,
|
||||
allIdps,
|
||||
@@ -97,11 +102,12 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
);
|
||||
|
||||
// Policy entries mapped to selector format
|
||||
const policyRoleItems = useMemo(
|
||||
const policyRoleItems = useMemo<OverlaySelectedRole[]>(
|
||||
() =>
|
||||
policy.roles.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name
|
||||
text: r.name,
|
||||
isAdmin: false
|
||||
})),
|
||||
[policy.roles]
|
||||
);
|
||||
@@ -119,7 +125,8 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Combined selected roles/users (policy + resource-specific)
|
||||
const [combinedRoles, setCombinedRoles] = useState(policyRoleItems);
|
||||
const [combinedRoles, setCombinedRoles] =
|
||||
useState<OverlaySelectedRole[]>(policyRoleItems);
|
||||
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
||||
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
||||
useState(false);
|
||||
@@ -132,12 +139,20 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
|
||||
const resourceSpecific = resourceRolesData
|
||||
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
||||
.map((r) => ({ id: r.roleId.toString(), text: r.name }));
|
||||
.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name,
|
||||
isAdmin: Boolean(r.isAdmin)
|
||||
}));
|
||||
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
resourceSpecific.map((r) => r.id)
|
||||
);
|
||||
setCombinedRoles([...policyRoleItems, ...resourceSpecific]);
|
||||
setCombinedRoles(
|
||||
[...policyRoleItems, ...resourceSpecific].filter(
|
||||
(role) => !role.isAdmin
|
||||
)
|
||||
);
|
||||
setResourceRolesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
@@ -253,59 +268,29 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
setIsSavingOverlay(true);
|
||||
try {
|
||||
// Compute which roles/users are resource-specific (non-locked)
|
||||
const currentResourceRoleIds = new Set(
|
||||
combinedRoles
|
||||
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||
.map((r) => r.id)
|
||||
);
|
||||
const currentResourceUserIds = new Set(
|
||||
combinedUsers
|
||||
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||
.map((u) => u.id)
|
||||
);
|
||||
|
||||
const initialRoleIds = initialResourceRoleIdsRef.current;
|
||||
const initialUserIds = initialResourceUserIdsRef.current;
|
||||
|
||||
const addedRoleIds = [...currentResourceRoleIds].filter(
|
||||
(id) => !initialRoleIds.has(id)
|
||||
);
|
||||
const removedRoleIds = [...initialRoleIds].filter(
|
||||
(id) => !currentResourceRoleIds.has(id)
|
||||
);
|
||||
const addedUserIds = [...currentResourceUserIds].filter(
|
||||
(id) => !initialUserIds.has(id)
|
||||
);
|
||||
const removedUserIds = [...initialUserIds].filter(
|
||||
(id) => !currentResourceUserIds.has(id)
|
||||
);
|
||||
const currentResourceRoleIds = combinedRoles
|
||||
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||
.map((r) => Number(r.id));
|
||||
const currentResourceUserIds = combinedUsers
|
||||
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||
.map((u) => u.id);
|
||||
|
||||
// Use bulk-set endpoints (session-authenticated) which replace
|
||||
// all resource-specific roles/users in one call
|
||||
await Promise.all([
|
||||
...addedRoleIds.map((id) =>
|
||||
api.post(`/resource/${resourceId}/roles/add`, {
|
||||
roleId: Number(id)
|
||||
})
|
||||
),
|
||||
...removedRoleIds.map((id) =>
|
||||
api.post(`/resource/${resourceId}/roles/remove`, {
|
||||
roleId: Number(id)
|
||||
})
|
||||
),
|
||||
...addedUserIds.map((id) =>
|
||||
api.post(`/resource/${resourceId}/users/add`, {
|
||||
userId: id
|
||||
})
|
||||
),
|
||||
...removedUserIds.map((id) =>
|
||||
api.post(`/resource/${resourceId}/users/remove`, {
|
||||
userId: id
|
||||
})
|
||||
)
|
||||
api.post(`/resource/${resourceId}/roles`, {
|
||||
roleIds: currentResourceRoleIds
|
||||
}),
|
||||
api.post(`/resource/${resourceId}/users`, {
|
||||
userIds: currentResourceUserIds
|
||||
})
|
||||
]);
|
||||
|
||||
// Update refs to reflect new state
|
||||
initialResourceRoleIdsRef.current = currentResourceRoleIds;
|
||||
initialResourceUserIdsRef.current = currentResourceUserIds;
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
currentResourceRoleIds.map(String)
|
||||
);
|
||||
initialResourceUserIdsRef.current = new Set(currentResourceUserIds);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
@@ -362,12 +347,27 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
{isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
combinedRoles
|
||||
}
|
||||
onSelectRoles={
|
||||
setCombinedRoles
|
||||
}
|
||||
selectedRoles={combinedRoles.filter(
|
||||
(role) => !role.isAdmin
|
||||
)}
|
||||
onSelectRoles={(roles) => {
|
||||
setCombinedRoles(
|
||||
roles
|
||||
.map(
|
||||
(role) => ({
|
||||
...role,
|
||||
isAdmin:
|
||||
Boolean(
|
||||
role.isAdmin
|
||||
)
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(role) =>
|
||||
!role.isAdmin
|
||||
)
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={
|
||||
|
||||
Reference in New Issue
Block a user