Add policies to blueprints

This commit is contained in:
Owen
2026-05-04 20:42:02 -07:00
parent f4602a120e
commit fc2c13a686
2 changed files with 909 additions and 315 deletions

View File

@@ -16,7 +16,15 @@ import {
Transaction, Transaction,
userOrgs, userOrgs,
userResources, userResources,
users users,
resourcePolicies,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyHeaderAuth,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
userPolicies
} from "@server/db"; } from "@server/db";
import { resources, targets, sites } from "@server/db"; import { resources, targets, sites } from "@server/db";
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
@@ -30,6 +38,7 @@ import logger from "@server/logger";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { pickPort } from "@server/routers/target/helpers"; import { pickPort } from "@server/routers/target/helpers";
import { resourcePassword } from "@server/db"; import { resourcePassword } from "@server/db";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
@@ -242,6 +251,39 @@ export async function updateProxyResources(
resourceData.maintenance = undefined; resourceData.maintenance = undefined;
} }
// Look up the admin role (needed for inline policy creation)
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error(`Admin role not found`);
}
if (resourceData.policy) {
// SHARED POLICY MODE: look up shared policy by niceId
const [sharedPolicy] = await trx
.select()
.from(resourcePolicies)
.where(
and(
eq(
resourcePolicies.niceId,
resourceData.policy
),
eq(resourcePolicies.orgId, orgId)
)
)
.limit(1);
if (!sharedPolicy) {
throw new Error(
`Shared policy not found: ${resourceData.policy} in org ${orgId}`
);
}
[resource] = await trx [resource] = await trx
.update(resources) .update(resources)
.set({ .set({
@@ -249,7 +291,9 @@ export async function updateProxyResources(
protocol: protocol || "tcp", protocol: protocol || "tcp",
http: http, http: http,
proxyPort: http ? null : resourceData["proxy-port"], proxyPort: http ? null : resourceData["proxy-port"],
fullDomain: http ? resourceData["full-domain"] : null, fullDomain: http
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null, subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null, domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false, wildcard: domain ? domain.wildcard : false,
@@ -259,28 +303,37 @@ export async function updateProxyResources(
resourceData.auth?.["auto-login-idp"] || null, resourceData.auth?.["auto-login-idp"] || null,
ssl: resourceSsl, ssl: resourceSsl,
setHostHeader: resourceData["host-header"] || null, setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null, tlsServerName:
resourceData["tls-server-name"] || null,
emailWhitelistEnabled: resourceData.auth?.[ emailWhitelistEnabled: resourceData.auth?.[
"whitelist-users" "whitelist-users"
] ]
? resourceData.auth["whitelist-users"].length > 0 ? resourceData.auth["whitelist-users"].length >
0
: false, : false,
headers: headers || null, headers: headers || null,
applyRules: applyRules:
resourceData.rules && resourceData.rules.length > 0, resourceData.rules &&
resourceData.rules.length > 0,
maintenanceModeEnabled: maintenanceModeEnabled:
resourceData.maintenance?.enabled, resourceData.maintenance?.enabled,
maintenanceModeType: resourceData.maintenance?.type, maintenanceModeType: resourceData.maintenance?.type,
maintenanceTitle: resourceData.maintenance?.title, maintenanceTitle: resourceData.maintenance?.title,
maintenanceMessage: resourceData.maintenance?.message, maintenanceMessage:
resourceData.maintenance?.message,
maintenanceEstimatedTime: maintenanceEstimatedTime:
resourceData.maintenance?.["estimated-time"] resourceData.maintenance?.["estimated-time"],
resourcePolicyId: sharedPolicy.resourcePolicyId
}) })
.where( .where(
eq(resources.resourceId, existingResource.resourceId) eq(
resources.resourceId,
existingResource.resourceId
)
) )
.returning(); .returning();
// Update OLD resource-level auth tables
await trx await trx
.delete(resourcePassword) .delete(resourcePassword)
.where( .where(
@@ -293,7 +346,6 @@ export async function updateProxyResources(
const passwordHash = await hashPassword( const passwordHash = await hashPassword(
resourceData.auth.password resourceData.auth.password
); );
await trx.insert(resourcePassword).values({ await trx.insert(resourcePassword).values({
resourceId: existingResource.resourceId, resourceId: existingResource.resourceId,
passwordHash passwordHash
@@ -312,7 +364,6 @@ export async function updateProxyResources(
const pincodeHash = await hashPassword( const pincodeHash = await hashPassword(
resourceData.auth.pincode.toString() resourceData.auth.pincode.toString()
); );
await trx.insert(resourcePincode).values({ await trx.insert(resourcePincode).values({
resourceId: existingResource.resourceId, resourceId: existingResource.resourceId,
pincodeHash, pincodeHash,
@@ -328,7 +379,6 @@ export async function updateProxyResources(
existingResource.resourceId existingResource.resourceId
) )
); );
await trx await trx
.delete(resourceHeaderAuthExtendedCompatibility) .delete(resourceHeaderAuthExtendedCompatibility)
.where( .where(
@@ -337,14 +387,13 @@ export async function updateProxyResources(
existingResource.resourceId existingResource.resourceId
) )
); );
if (resourceData.auth?.["basic-auth"]) { if (resourceData.auth?.["basic-auth"]) {
const headerAuthUser = const headerAuthUser =
resourceData.auth?.["basic-auth"]?.user; resourceData.auth["basic-auth"]?.user;
const headerAuthPassword = const headerAuthPassword =
resourceData.auth?.["basic-auth"]?.password; resourceData.auth["basic-auth"]?.password;
const headerAuthExtendedCompatibility = const headerAuthExtendedCompatibility =
resourceData.auth?.["basic-auth"] resourceData.auth["basic-auth"]
?.extendedCompatibility; ?.extendedCompatibility;
if ( if (
headerAuthUser && headerAuthUser &&
@@ -362,7 +411,9 @@ export async function updateProxyResources(
headerAuthHash headerAuthHash
}), }),
trx trx
.insert(resourceHeaderAuthExtendedCompatibility) .insert(
resourceHeaderAuthExtendedCompatibility
)
.values({ .values({
resourceId: existingResource.resourceId, resourceId: existingResource.resourceId,
extendedCompatibilityIsActivated: extendedCompatibilityIsActivated:
@@ -373,35 +424,87 @@ export async function updateProxyResources(
} }
if (resourceData.auth?.["sso-roles"]) { if (resourceData.auth?.["sso-roles"]) {
const ssoRoles = resourceData.auth?.["sso-roles"];
await syncRoleResources( await syncRoleResources(
existingResource.resourceId, existingResource.resourceId,
ssoRoles, resourceData.auth["sso-roles"],
orgId, orgId,
trx trx
); );
} }
if (resourceData.auth?.["sso-users"]) { if (resourceData.auth?.["sso-users"]) {
const ssoUsers = resourceData.auth?.["sso-users"];
await syncUserResources( await syncUserResources(
existingResource.resourceId, existingResource.resourceId,
ssoUsers, resourceData.auth["sso-users"],
orgId, orgId,
trx trx
); );
} }
if (resourceData.auth?.["whitelist-users"]) { if (resourceData.auth?.["whitelist-users"]) {
const whitelistUsers =
resourceData.auth?.["whitelist-users"];
await syncWhitelistUsers( await syncWhitelistUsers(
existingResource.resourceId, existingResource.resourceId,
whitelistUsers, resourceData.auth["whitelist-users"],
orgId, orgId,
trx trx
); );
} }
} else {
// INLINE POLICY MODE: ensure inline policy exists
const inlinePolicyId = await ensureInlinePolicy(
existingResource.defaultResourcePolicyId,
orgId,
resourceNiceId,
adminRole.roleId,
trx
);
[resource] = await trx
.update(resources)
.set({
name: resourceData.name || "Unnamed Resource",
protocol: protocol || "tcp",
http: http,
proxyPort: http ? null : resourceData["proxy-port"],
fullDomain: http
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false,
enabled: resourceEnabled,
ssl: resourceSsl,
setHostHeader: resourceData["host-header"] || null,
tlsServerName:
resourceData["tls-server-name"] || null,
headers: headers || null,
maintenanceModeEnabled:
resourceData.maintenance?.enabled,
maintenanceModeType: resourceData.maintenance?.type,
maintenanceTitle: resourceData.maintenance?.title,
maintenanceMessage:
resourceData.maintenance?.message,
maintenanceEstimatedTime:
resourceData.maintenance?.["estimated-time"],
resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId
})
.where(
eq(
resources.resourceId,
existingResource.resourceId
)
)
.returning();
// Update inline policy auth fields and policy-level tables
await syncInlinePolicyAuth(
inlinePolicyId,
orgId,
resourceData,
trx
);
}
} }
const existingResourceTargets = await trx const existingResourceTargets = await trx
@@ -618,21 +721,28 @@ export async function updateProxyResources(
} }
} }
if (resourceData.policy) {
// SHARED POLICY MODE: sync rules into old resourceRules table
const existingRules = await trx const existingRules = await trx
.select() .select()
.from(resourceRules) .from(resourceRules)
.where( .where(
eq(resourceRules.resourceId, existingResource.resourceId) eq(
resourceRules.resourceId,
existingResource.resourceId
)
) )
.orderBy(resourceRules.priority); .orderBy(resourceRules.priority);
// Sync rules // Sync rules
for (const [index, rule] of resourceData.rules?.entries() || []) { for (const [index, rule] of resourceData.rules?.entries() ||
[]) {
const intendedPriority = rule.priority ?? index + 1; const intendedPriority = rule.priority ?? index + 1;
const existingRule = existingRules[index]; const existingRule = existingRules[index];
if (existingRule) { if (existingRule) {
if ( if (
existingRule.action !== getRuleAction(rule.action) || existingRule.action !==
getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() || existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !== existingRule.value !==
getRuleValue( getRuleValue(
@@ -654,7 +764,10 @@ export async function updateProxyResources(
priority: intendedPriority priority: intendedPriority
}) })
.where( .where(
eq(resourceRules.ruleId, existingRule.ruleId) eq(
resourceRules.ruleId,
existingRule.ruleId
)
); );
} }
} else { } else {
@@ -682,6 +795,15 @@ export async function updateProxyResources(
.where(eq(resourceRules.ruleId, rule.ruleId)); .where(eq(resourceRules.ruleId, rule.ruleId));
} }
} }
} else {
// INLINE POLICY MODE: sync rules into policy-level table
const inlinePolicyId = resource!.defaultResourcePolicyId!;
await syncInlinePolicyRules(
inlinePolicyId,
resourceData.rules || [],
trx
);
}
logger.debug(`Updated resource ${existingResource.resourceId}`); logger.debug(`Updated resource ${existingResource.resourceId}`);
} else { } else {
@@ -704,6 +826,58 @@ export async function updateProxyResources(
resourceData.maintenance = undefined; resourceData.maintenance = undefined;
} }
// Look up admin role (needed for inline policy and roleResources)
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error(`Admin role not found`);
}
// Always create an inline policy for the resource
const policyNiceId = await getUniqueResourcePolicyName(orgId);
const [inlinePolicy] = await trx
.insert(resourcePolicies)
.values({
niceId: policyNiceId,
orgId,
name: `default policy for ${resourceNiceId}`,
sso: true,
scope: "resource"
})
.returning();
// Make the inline policy visible to the admin role
await trx.insert(rolePolicies).values({
roleId: adminRole.roleId,
resourcePolicyId: inlinePolicy.resourcePolicyId
});
// Determine the active shared policy (if provided)
let sharedPolicyId: number | null = null;
if (resourceData.policy) {
const [sharedPolicy] = await trx
.select()
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, resourceData.policy),
eq(resourcePolicies.orgId, orgId)
)
)
.limit(1);
if (!sharedPolicy) {
throw new Error(
`Shared policy not found: ${resourceData.policy} in org ${orgId}`
);
}
sharedPolicyId = sharedPolicy.resourcePolicyId;
}
// Create new resource // Create new resource
const [newResource] = await trx const [newResource] = await trx
.insert(resources) .insert(resources)
@@ -719,28 +893,51 @@ export async function updateProxyResources(
domainId: domain ? domain.domainId : null, domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false, wildcard: domain ? domain.wildcard : false,
enabled: resourceEnabled, enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
setHostHeader: resourceData["host-header"] || null, setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null, tlsServerName: resourceData["tls-server-name"] || null,
ssl: resourceSsl, ssl: resourceSsl,
headers: headers || null, headers: headers || null,
applyRules:
resourceData.rules && resourceData.rules.length > 0,
maintenanceModeEnabled: resourceData.maintenance?.enabled, maintenanceModeEnabled: resourceData.maintenance?.enabled,
maintenanceModeType: resourceData.maintenance?.type, maintenanceModeType: resourceData.maintenance?.type,
maintenanceTitle: resourceData.maintenance?.title, maintenanceTitle: resourceData.maintenance?.title,
maintenanceMessage: resourceData.maintenance?.message, maintenanceMessage: resourceData.maintenance?.message,
maintenanceEstimatedTime: maintenanceEstimatedTime:
resourceData.maintenance?.["estimated-time"] resourceData.maintenance?.["estimated-time"],
defaultResourcePolicyId: inlinePolicy.resourcePolicyId,
resourcePolicyId: sharedPolicyId,
// Only set these resource-level fields when using a shared policy
...(sharedPolicyId
? {
sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId:
resourceData.auth?.["auto-login-idp"] || null,
emailWhitelistEnabled: resourceData.auth?.[
"whitelist-users"
]
? resourceData.auth["whitelist-users"]
.length > 0
: false,
applyRules:
resourceData.rules &&
resourceData.rules.length > 0
}
: {})
}) })
.returning(); .returning();
resource = newResource;
await trx.insert(roleResources).values({
roleId: adminRole.roleId,
resourceId: newResource.resourceId
});
if (sharedPolicyId) {
// SHARED POLICY MODE: update OLD resource-level auth tables
if (resourceData.auth?.password) { if (resourceData.auth?.password) {
const passwordHash = await hashPassword( const passwordHash = await hashPassword(
resourceData.auth.password resourceData.auth.password
); );
await trx.insert(resourcePassword).values({ await trx.insert(resourcePassword).values({
resourceId: newResource.resourceId, resourceId: newResource.resourceId,
passwordHash passwordHash
@@ -751,7 +948,6 @@ export async function updateProxyResources(
const pincodeHash = await hashPassword( const pincodeHash = await hashPassword(
resourceData.auth.pincode.toString() resourceData.auth.pincode.toString()
); );
await trx.insert(resourcePincode).values({ await trx.insert(resourcePincode).values({
resourceId: newResource.resourceId, resourceId: newResource.resourceId,
pincodeHash, pincodeHash,
@@ -760,12 +956,12 @@ export async function updateProxyResources(
} }
if (resourceData.auth?.["basic-auth"]) { if (resourceData.auth?.["basic-auth"]) {
const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; const headerAuthUser =
resourceData.auth["basic-auth"]?.user;
const headerAuthPassword = const headerAuthPassword =
resourceData.auth?.["basic-auth"]?.password; resourceData.auth["basic-auth"]?.password;
const headerAuthExtendedCompatibility = const headerAuthExtendedCompatibility =
resourceData.auth?.["basic-auth"]?.extendedCompatibility; resourceData.auth["basic-auth"]?.extendedCompatibility;
if ( if (
headerAuthUser && headerAuthUser &&
headerAuthPassword && headerAuthPassword &&
@@ -776,7 +972,6 @@ export async function updateProxyResources(
`${headerAuthUser}:${headerAuthPassword}` `${headerAuthUser}:${headerAuthPassword}`
).toString("base64") ).toString("base64")
); );
await Promise.all([ await Promise.all([
trx.insert(resourceHeaderAuth).values({ trx.insert(resourceHeaderAuth).values({
resourceId: newResource.resourceId, resourceId: newResource.resourceId,
@@ -793,53 +988,65 @@ export async function updateProxyResources(
} }
} }
resource = newResource;
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error(`Admin role not found`);
}
await trx.insert(roleResources).values({
roleId: adminRole.roleId,
resourceId: newResource.resourceId
});
if (resourceData.auth?.["sso-roles"]) { if (resourceData.auth?.["sso-roles"]) {
const ssoRoles = resourceData.auth?.["sso-roles"];
await syncRoleResources( await syncRoleResources(
newResource.resourceId, newResource.resourceId,
ssoRoles, resourceData.auth["sso-roles"],
orgId, orgId,
trx trx
); );
} }
if (resourceData.auth?.["sso-users"]) { if (resourceData.auth?.["sso-users"]) {
const ssoUsers = resourceData.auth?.["sso-users"];
await syncUserResources( await syncUserResources(
newResource.resourceId, newResource.resourceId,
ssoUsers, resourceData.auth["sso-users"],
orgId, orgId,
trx trx
); );
} }
if (resourceData.auth?.["whitelist-users"]) { if (resourceData.auth?.["whitelist-users"]) {
const whitelistUsers = resourceData.auth?.["whitelist-users"];
await syncWhitelistUsers( await syncWhitelistUsers(
newResource.resourceId, newResource.resourceId,
whitelistUsers, resourceData.auth["whitelist-users"],
orgId, orgId,
trx trx
); );
} }
// Rules into OLD resourceRules table
for (const [index, rule] of resourceData.rules?.entries() ||
[]) {
validateRule(rule);
await trx.insert(resourceRules).values({
resourceId: newResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: getRuleValue(
rule.match.toUpperCase(),
rule.value
),
priority: rule.priority ?? index + 1
});
}
} else {
// INLINE POLICY MODE: update the inline policy auth fields
await syncInlinePolicyAuth(
inlinePolicy.resourcePolicyId,
orgId,
resourceData,
trx
);
// Rules into policy-level table
await syncInlinePolicyRules(
inlinePolicy.resourcePolicyId,
resourceData.rules || [],
trx
);
}
// Create new targets // Create new targets
for (const targetData of resourceData.targets) { for (const targetData of resourceData.targets) {
if (!targetData) { if (!targetData) {
@@ -849,17 +1056,6 @@ export async function updateProxyResources(
await createTarget(newResource.resourceId, targetData); await createTarget(newResource.resourceId, targetData);
} }
for (const [index, rule] of resourceData.rules?.entries() || []) {
validateRule(rule);
await trx.insert(resourceRules).values({
resourceId: newResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: getRuleValue(rule.match.toUpperCase(), rule.value),
priority: rule.priority ?? index + 1
});
}
logger.debug(`Created resource ${newResource.resourceId}`); logger.debug(`Created resource ${newResource.resourceId}`);
} }
@@ -1097,6 +1293,399 @@ async function syncWhitelistUsers(
} }
} }
/**
* Creates an inline resourcePolicy if one doesn't exist yet, and returns its ID.
* Makes the policy visible to the admin role via rolePolicies.
*/
async function ensureInlinePolicy(
existingPolicyId: number | null | undefined,
orgId: string,
resourceNiceId: string,
adminRoleId: number,
trx: Transaction
): Promise<number> {
if (existingPolicyId) {
return existingPolicyId;
}
const policyNiceId = await getUniqueResourcePolicyName(orgId);
const [newPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId: policyNiceId,
orgId,
name: `default policy for ${resourceNiceId}`,
sso: true,
scope: "resource"
})
.returning();
await trx.insert(rolePolicies).values({
roleId: adminRoleId,
resourcePolicyId: newPolicy.resourcePolicyId
});
return newPolicy.resourcePolicyId;
}
/**
* Updates the inline policy's auth-related fields and policy-level tables
* (used when no shared policy is specified in the blueprint).
*/
async function syncInlinePolicyAuth(
policyId: number,
orgId: string,
resourceData: any,
trx: Transaction
) {
// Update policy-level SSO/whitelist/applyRules fields
await trx
.update(resourcePolicies)
.set({
sso: resourceData.auth?.["sso-enabled"] ?? false,
idpId: resourceData.auth?.["auto-login-idp"] || null,
emailWhitelistEnabled: resourceData.auth?.["whitelist-users"]
? resourceData.auth["whitelist-users"].length > 0
: false,
applyRules: !!(resourceData.rules && resourceData.rules.length > 0)
})
.where(eq(resourcePolicies.resourcePolicyId, policyId));
// Password
await trx
.delete(resourcePolicyPassword)
.where(eq(resourcePolicyPassword.resourcePolicyId, policyId));
if (resourceData.auth?.password) {
const passwordHash = await hashPassword(resourceData.auth.password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId: policyId,
passwordHash
});
}
// Pincode
await trx
.delete(resourcePolicyPincode)
.where(eq(resourcePolicyPincode.resourcePolicyId, policyId));
if (resourceData.auth?.pincode) {
const pincodeHash = await hashPassword(
resourceData.auth.pincode.toString()
);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId: policyId,
pincodeHash,
digitLength: 6
});
}
// Header auth
await trx
.delete(resourcePolicyHeaderAuth)
.where(eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId));
if (resourceData.auth?.["basic-auth"]) {
const headerAuthUser = resourceData.auth["basic-auth"]?.user;
const headerAuthPassword = resourceData.auth["basic-auth"]?.password;
const headerAuthExtendedCompatibility =
resourceData.auth["basic-auth"]?.extendedCompatibility;
if (
headerAuthUser &&
headerAuthPassword &&
headerAuthExtendedCompatibility !== null
) {
const headerAuthHash = await hashPassword(
Buffer.from(`${headerAuthUser}:${headerAuthPassword}`).toString(
"base64"
)
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId: policyId,
headerAuthHash,
extendedCompatibility: headerAuthExtendedCompatibility
});
}
}
// SSO roles → rolePolicies
if (resourceData.auth?.["sso-roles"] !== undefined) {
await syncRolePolicies(
policyId,
resourceData.auth["sso-roles"],
orgId,
trx
);
}
// SSO users → userPolicies
if (resourceData.auth?.["sso-users"] !== undefined) {
await syncUserPolicies(
policyId,
resourceData.auth["sso-users"],
orgId,
trx
);
}
// Whitelist → resourcePolicyWhiteList
if (resourceData.auth?.["whitelist-users"] !== undefined) {
await syncWhitelistPolicyUsers(
policyId,
resourceData.auth["whitelist-users"],
orgId,
trx
);
}
}
/**
* Syncs rules into the resourcePolicyRules table (inline policy mode).
*/
async function syncInlinePolicyRules(
policyId: number,
rules: any[],
trx: Transaction
) {
const existingRules = await trx
.select()
.from(resourcePolicyRules)
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
.orderBy(resourcePolicyRules.priority);
for (const [index, rule] of rules.entries()) {
const intendedPriority = rule.priority ?? index + 1;
const existingRule = existingRules[index];
if (existingRule) {
if (
existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !==
getRuleValue(rule.match.toUpperCase(), rule.value) ||
existingRule.priority !== intendedPriority
) {
validateRule(rule);
await trx
.update(resourcePolicyRules)
.set({
action: getRuleAction(rule.action) as
| "ACCEPT"
| "DROP"
| "PASS",
match: rule.match.toUpperCase() as
| "CIDR"
| "IP"
| "PATH",
value: getRuleValue(
rule.match.toUpperCase(),
rule.value
),
priority: intendedPriority
})
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
}
} else {
validateRule(rule);
await trx.insert(resourcePolicyRules).values({
resourcePolicyId: policyId,
action: getRuleAction(rule.action) as
| "ACCEPT"
| "DROP"
| "PASS",
match: rule.match.toUpperCase() as "CIDR" | "IP" | "PATH",
value: getRuleValue(rule.match.toUpperCase(), rule.value),
priority: intendedPriority
});
}
}
if (existingRules.length > rules.length) {
const rulesToDelete = existingRules.slice(rules.length);
for (const rule of rulesToDelete) {
await trx
.delete(resourcePolicyRules)
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
}
}
}
/**
* Syncs SSO roles to the rolePolicies table (inline policy mode).
*/
async function syncRolePolicies(
policyId: number,
ssoRoles: string[],
orgId: string,
trx: Transaction
) {
const existingRolePoliciesList = await trx
.select()
.from(rolePolicies)
.where(eq(rolePolicies.resourcePolicyId, policyId));
for (const roleName of ssoRoles) {
const [role] = await trx
.select()
.from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1);
if (!role) {
throw new Error(`Role not found: ${roleName} in org ${orgId}`);
}
if (role.isAdmin) {
continue;
}
const existingRolePolicy = existingRolePoliciesList.find(
(rp) => rp.roleId === role.roleId
);
if (!existingRolePolicy) {
await trx.insert(rolePolicies).values({
roleId: role.roleId,
resourcePolicyId: policyId
});
}
}
for (const existingRolePolicy of existingRolePoliciesList) {
const [role] = await trx
.select()
.from(roles)
.where(eq(roles.roleId, existingRolePolicy.roleId))
.limit(1);
if (role?.isAdmin) {
continue;
}
if (role && !ssoRoles.includes(role.name)) {
await trx
.delete(rolePolicies)
.where(
and(
eq(rolePolicies.roleId, existingRolePolicy.roleId),
eq(rolePolicies.resourcePolicyId, policyId)
)
);
}
}
}
/**
* Syncs SSO users to the userPolicies table (inline policy mode).
*/
async function syncUserPolicies(
policyId: number,
ssoUsers: string[],
orgId: string,
trx: Transaction
) {
const existingUserPoliciesList = await trx
.select()
.from(userPolicies)
.where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) {
throw new Error(`User not found: ${username} in org ${orgId}`);
}
const existingUserPolicy = existingUserPoliciesList.find(
(up) => up.userId === user.user.userId
);
if (!existingUserPolicy) {
await trx.insert(userPolicies).values({
userId: user.user.userId,
resourcePolicyId: policyId
});
}
}
for (const existingUserPolicy of existingUserPoliciesList) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
eq(users.userId, existingUserPolicy.userId),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (
user &&
user.user.username &&
!ssoUsers.includes(user.user.username)
) {
await trx
.delete(userPolicies)
.where(
and(
eq(userPolicies.userId, existingUserPolicy.userId),
eq(userPolicies.resourcePolicyId, policyId)
)
);
}
}
}
/**
* Syncs whitelist emails to the resourcePolicyWhiteList table (inline policy mode).
*/
async function syncWhitelistPolicyUsers(
policyId: number,
whitelistUsers: string[],
orgId: string,
trx: Transaction
) {
const existingWhitelist = await trx
.select()
.from(resourcePolicyWhiteList)
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
for (const email of whitelistUsers) {
const existingEntry = existingWhitelist.find((w) => w.email === email);
if (!existingEntry) {
await trx.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
}
}
for (const existingEntry of existingWhitelist) {
if (!whitelistUsers.includes(existingEntry.email)) {
await trx
.delete(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.whitelistId,
existingEntry.whitelistId
),
eq(resourcePolicyWhiteList.resourcePolicyId, policyId)
)
);
}
}
}
function checkIfHealthcheckChanged( function checkIfHealthcheckChanged(
existing: TargetHealthCheck | undefined, existing: TargetHealthCheck | undefined,
incoming: TargetHealthCheck | undefined incoming: TargetHealthCheck | undefined

View File

@@ -162,9 +162,10 @@ export const HeaderSchema = z.object({
}); });
// Schema for individual resource // Schema for individual resource
export const ResourceSchema = z export const PublicResourceSchema = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
policy: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(), protocol: z.enum(["http", "tcp", "udp"]).optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(), scheme: z.enum(["http", "https"]).optional(),
@@ -340,7 +341,8 @@ export const ResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/; const labelRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label)); return parts.slice(1).every((label) => labelRegex.test(label));
}, },
{ {
@@ -354,7 +356,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets; return Object.keys(resource).length === 1 && resource.targets;
} }
export const ClientResourceSchema = z export const PrivateResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr", "http"]),
@@ -435,19 +437,19 @@ export const ClientResourceSchema = z
export const ConfigSchema = z export const ConfigSchema = z
.object({ .object({
"proxy-resources": z "proxy-resources": z
.record(z.string(), ResourceSchema) .record(z.string(), PublicResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"public-resources": z "public-resources": z
.record(z.string(), ResourceSchema) .record(z.string(), PublicResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"client-resources": z "client-resources": z
.record(z.string(), ClientResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"private-resources": z "private-resources": z
.record(z.string(), ClientResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({}) sites: z.record(z.string(), SiteSchema).optional().prefault({})
@@ -472,10 +474,13 @@ export const ConfigSchema = z
} }
return data as { return data as {
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>; "proxy-resources": Record<
string,
z.infer<typeof PublicResourceSchema>
>;
"client-resources": Record< "client-resources": Record<
string, string,
z.infer<typeof ClientResourceSchema> z.infer<typeof PrivateResourceSchema>
>; >;
sites: Record<string, z.infer<typeof SiteSchema>>; sites: Record<string, z.infer<typeof SiteSchema>>;
}; };
@@ -614,5 +619,5 @@ export const ConfigSchema = z
// Type inference from the schema // Type inference from the schema
export type Site = z.infer<typeof SiteSchema>; export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>; export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof ResourceSchema>; export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;