Compare commits

..

2 Commits

Author SHA1 Message Date
miloschwartz
20e547a0f6 first pass 2026-02-24 17:58:11 -08:00
miloschwartz
848d4d91e6 fix sidebar 2026-02-23 13:40:08 -08:00
91 changed files with 1234 additions and 1186 deletions

View File

@@ -28,9 +28,9 @@ LICENSE
CONTRIBUTING.md CONTRIBUTING.md
dist dist
.git .git
server/migrations/ migrations/
config/ config/
build.ts build.ts
tsconfig.json tsconfig.json
Dockerfile* Dockerfile*
drizzle.config.ts migrations/

View File

@@ -1,8 +1,8 @@
FROM node:24-slim AS base FROM node:24-alpine AS base
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* RUN apk add --no-cache python3 make g++
COPY package*.json ./ COPY package*.json ./
@@ -27,11 +27,11 @@ FROM base AS builder
RUN npm ci --omit=dev RUN npm ci --omit=dev
FROM node:24-slim AS runner FROM node:24-alpine AS runner
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y curl tzdata && rm -rf /var/lib/apt/lists/* RUN apk add --no-cache curl tzdata
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json

View File

@@ -1,7 +1,7 @@
import { Request } from "express"; import { Request } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { userActions, roleActions, userOrgs } from "@server/db"; import { userActions, roleActions } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -52,6 +52,7 @@ export enum ActionsEnum {
listRoleResources = "listRoleResources", listRoleResources = "listRoleResources",
// listRoleActions = "listRoleActions", // listRoleActions = "listRoleActions",
addUserRole = "addUserRole", addUserRole = "addUserRole",
removeUserRole = "removeUserRole",
// addUserSite = "addUserSite", // addUserSite = "addUserSite",
// addUserAction = "addUserAction", // addUserAction = "addUserAction",
// removeUserAction = "removeUserAction", // removeUserAction = "removeUserAction",
@@ -153,29 +154,19 @@ export async function checkUserActionPermission(
} }
try { try {
let userOrgRoleId = req.userOrgRoleId; let userOrgRoleIds = req.userOrgRoleIds;
// If userOrgRoleId is not available on the request, fetch it if (userOrgRoleIds === undefined) {
if (userOrgRoleId === undefined) { const { getUserOrgRoleIds } = await import(
const userOrgRole = await db "@server/lib/userOrgRoles"
.select() );
.from(userOrgs) userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!);
.where( if (userOrgRoleIds.length === 0) {
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, req.userOrgId!)
)
)
.limit(1);
if (userOrgRole.length === 0) {
throw createHttpError( throw createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this organization" "User does not have access to this organization"
); );
} }
userOrgRoleId = userOrgRole[0].roleId;
} }
// Check if the user has direct permission for the action in the current org // Check if the user has direct permission for the action in the current org
@@ -186,7 +177,7 @@ export async function checkUserActionPermission(
and( and(
eq(userActions.userId, userId), eq(userActions.userId, userId),
eq(userActions.actionId, actionId), eq(userActions.actionId, actionId),
eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org eq(userActions.orgId, req.userOrgId!)
) )
) )
.limit(1); .limit(1);
@@ -195,14 +186,14 @@ export async function checkUserActionPermission(
return true; return true;
} }
// If no direct permission, check role-based permission // If no direct permission, check role-based permission (any of user's roles)
const roleActionPermission = await db const roleActionPermission = await db
.select() .select()
.from(roleActions) .from(roleActions)
.where( .where(
and( and(
eq(roleActions.actionId, actionId), eq(roleActions.actionId, actionId),
eq(roleActions.roleId, userOrgRoleId!), inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!) eq(roleActions.orgId, req.userOrgId!)
) )
) )

View File

@@ -1,26 +1,29 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { roleResources, userResources } from "@server/db"; import { roleResources, userResources } from "@server/db";
export async function canUserAccessResource({ export async function canUserAccessResource({
userId, userId,
resourceId, resourceId,
roleId roleIds
}: { }: {
userId: string; userId: string;
resourceId: number; resourceId: number;
roleId: number; roleIds: number[];
}): Promise<boolean> { }): Promise<boolean> {
const roleResourceAccess = await db const roleResourceAccess =
.select() roleIds.length > 0
.from(roleResources) ? await db
.where( .select()
and( .from(roleResources)
eq(roleResources.resourceId, resourceId), .where(
eq(roleResources.roleId, roleId) and(
) eq(roleResources.resourceId, resourceId),
) inArray(roleResources.roleId, roleIds)
.limit(1); )
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess.length > 0) {
return true; return true;

View File

@@ -1,26 +1,29 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { roleSiteResources, userSiteResources } from "@server/db"; import { roleSiteResources, userSiteResources } from "@server/db";
export async function canUserAccessSiteResource({ export async function canUserAccessSiteResource({
userId, userId,
resourceId, resourceId,
roleId roleIds
}: { }: {
userId: string; userId: string;
resourceId: number; resourceId: number;
roleId: number; roleIds: number[];
}): Promise<boolean> { }): Promise<boolean> {
const roleResourceAccess = await db const roleResourceAccess =
.select() roleIds.length > 0
.from(roleSiteResources) ? await db
.where( .select()
and( .from(roleSiteResources)
eq(roleSiteResources.siteResourceId, resourceId), .where(
eq(roleSiteResources.roleId, roleId) and(
) eq(roleSiteResources.siteResourceId, resourceId),
) inArray(roleSiteResources.roleId, roleIds)
.limit(1); )
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess.length > 0) {
return true; return true;

View File

@@ -1,5 +1,4 @@
export * from "./driver"; export * from "./driver";
export * from "./logsDriver";
export * from "./safeRead"; export * from "./safeRead";
export * from "./schema/schema"; export * from "./schema/schema";
export * from "./schema/privateSchema"; export * from "./schema/privateSchema";

View File

@@ -1,89 +0,0 @@
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { readConfigFile } from "@server/lib/readConfigFile";
import { readPrivateConfigFile } from "@server/private/lib/readConfigFile";
import { withReplicas } from "drizzle-orm/pg-core";
import { build } from "@server/build";
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
function createLogsDb() {
// Only use separate logs database in SaaS builds
if (build !== "saas") {
return mainDb;
}
const config = readConfigFile();
const privateConfig = readPrivateConfigFile();
// Merge configs, prioritizing private config
const logsConfig = privateConfig.postgres_logs || config.postgres_logs;
// Check environment variable first
let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING;
let replicaConnections: Array<{ connection_string: string }> = [];
if (!connectionString && logsConfig) {
connectionString = logsConfig.connection_string;
replicaConnections = logsConfig.replicas || [];
}
// If POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS is set, use it
if (process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS) {
replicaConnections =
process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS.split(",").map(
(conn) => ({
connection_string: conn.trim()
})
);
}
// If no logs database is configured, fall back to main database
if (!connectionString) {
return mainDb;
}
// Create separate connection pool for logs database
const poolConfig = logsConfig?.pool || config.postgres?.pool;
const primaryPool = new Pool({
connectionString,
max: poolConfig?.max_connections || 20,
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000
});
const replicas = [];
if (!replicaConnections.length) {
replicas.push(
DrizzlePostgres(primaryPool, {
logger: process.env.QUERY_LOGGING == "true"
})
);
} else {
for (const conn of replicaConnections) {
const replicaPool = new Pool({
connectionString: conn.connection_string,
max: poolConfig?.max_replica_connections || 20,
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
connectionTimeoutMillis:
poolConfig?.connection_timeout_ms || 5000
});
replicas.push(
DrizzlePostgres(replicaPool, {
logger: process.env.QUERY_LOGGING == "true"
})
);
}
}
return withReplicas(
DrizzlePostgres(primaryPool, {
logger: process.env.QUERY_LOGGING == "true"
}),
replicas as any
);
}
export const logsDb = createLogsDb();
export default logsDb;
export const primaryLogsDb = logsDb.$primary;

View File

@@ -9,6 +9,7 @@ import {
real, real,
serial, serial,
text, text,
unique,
varchar varchar
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
@@ -332,9 +333,6 @@ export const userOrgs = pgTable("userOrgs", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId),
isOwner: boolean("isOwner").notNull().default(false), isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false), autoProvisioned: boolean("autoProvisioned").default(false),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such pamUsername: varchar("pamUsername") // cleaned username for ssh and such
@@ -383,6 +381,22 @@ export const roles = pgTable("roles", {
sshUnixGroups: text("sshUnixGroups").default("[]") sshUnixGroups: text("sshUnixGroups").default("[]")
}); });
export const userOrgRoles = pgTable(
"userOrgRoles",
{
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" })
},
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
);
export const roleActions = pgTable("roleActions", { export const roleActions = pgTable("roleActions", {
roleId: integer("roleId") roleId: integer("roleId")
.notNull() .notNull()
@@ -1031,6 +1045,7 @@ export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>; export type UserResource = InferSelectModel<typeof userResources>;
export type UserInvite = InferSelectModel<typeof userInvites>; export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserOrg = InferSelectModel<typeof userOrgs>; export type UserOrg = InferSelectModel<typeof userOrgs>;
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>; export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>; export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>; export type ResourcePassword = InferSelectModel<typeof resourcePassword>;

View File

@@ -12,6 +12,7 @@ import {
resources, resources,
roleResources, roleResources,
sessions, sessions,
userOrgRoles,
userOrgs, userOrgs,
userResources, userResources,
users, users,
@@ -104,24 +105,57 @@ export async function getUserSessionWithUser(
} }
/** /**
* Get user organization role * Get user organization role (single role; prefer getUserOrgRoleIds + roles for multi-role).
* @deprecated Use userOrgRoles table and getUserOrgRoleIds for multi-role support.
*/ */
export async function getUserOrgRole(userId: string, orgId: string) { export async function getUserOrgRole(userId: string, orgId: string) {
const userOrgRole = await db const userOrg = await db
.select({ .select({
userId: userOrgs.userId, userId: userOrgs.userId,
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
roleId: userOrgs.roleId,
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
autoProvisioned: userOrgs.autoProvisioned, autoProvisioned: userOrgs.autoProvisioned
roleName: roles.name
}) })
.from(userOrgs) .from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.limit(1); .limit(1);
return userOrgRole.length > 0 ? userOrgRole[0] : null; if (userOrg.length === 0) return null;
const [firstRole] = await db
.select({
roleId: userOrgRoles.roleId,
roleName: roles.name
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
)
.limit(1);
return firstRole
? {
...userOrg[0],
roleId: firstRole.roleId,
roleName: firstRole.roleName
}
: { ...userOrg[0], roleId: null, roleName: null };
}
/**
* Get role name by role ID (for display).
*/
export async function getRoleName(roleId: number): Promise<string | null> {
const [row] = await db
.select({ name: roles.name })
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
return row?.name ?? null;
} }
/** /**

View File

@@ -1,5 +1,4 @@
export * from "./driver"; export * from "./driver";
export * from "./logsDriver";
export * from "./safeRead"; export * from "./safeRead";
export * from "./schema/schema"; export * from "./schema/schema";
export * from "./schema/privateSchema"; export * from "./schema/privateSchema";

View File

@@ -1,7 +0,0 @@
import { db as mainDb } from "./driver";
// SQLite doesn't support separate databases for logs in the same way as Postgres
// Always use the main database connection for SQLite
export const logsDb = mainDb;
export default logsDb;
export const primaryLogsDb = logsDb;

View File

@@ -1,6 +1,12 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm"; import { InferSelectModel } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import {
index,
integer,
sqliteTable,
text,
unique
} from "drizzle-orm/sqlite-core";
export const domains = sqliteTable("domains", { export const domains = sqliteTable("domains", {
domainId: text("domainId").primaryKey(), domainId: text("domainId").primaryKey(),
@@ -635,9 +641,6 @@ export const userOrgs = sqliteTable("userOrgs", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
autoProvisioned: integer("autoProvisioned", { autoProvisioned: integer("autoProvisioned", {
mode: "boolean" mode: "boolean"
@@ -692,6 +695,22 @@ export const roles = sqliteTable("roles", {
sshUnixGroups: text("sshUnixGroups").default("[]") sshUnixGroups: text("sshUnixGroups").default("[]")
}); });
export const userOrgRoles = sqliteTable(
"userOrgRoles",
{
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" })
},
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
);
export const roleActions = sqliteTable("roleActions", { export const roleActions = sqliteTable("roleActions", {
roleId: integer("roleId") roleId: integer("roleId")
.notNull() .notNull()
@@ -1126,6 +1145,7 @@ export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>; export type UserResource = InferSelectModel<typeof userResources>;
export type UserInvite = InferSelectModel<typeof userInvites>; export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserOrg = InferSelectModel<typeof userOrgs>; export type UserOrg = InferSelectModel<typeof userOrgs>;
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>; export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>; export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>; export type ResourcePassword = InferSelectModel<typeof resourcePassword>;

View File

@@ -74,7 +74,7 @@ declare global {
session: Session; session: Session;
userOrg?: UserOrg; userOrg?: UserOrg;
apiKeyOrg?: ApiKeyOrg; apiKeyOrg?: ApiKeyOrg;
userOrgRoleId?: number; userOrgRoleIds?: number[];
userOrgId?: string; userOrgId?: string;
userOrgIds?: string[]; userOrgIds?: string[];
remoteExitNode?: RemoteExitNode; remoteExitNode?: RemoteExitNode;

View File

@@ -16,11 +16,6 @@ const internalPort = config.getRawConfig().server.internal_port;
export function createInternalServer() { export function createInternalServer() {
const internalServer = express(); const internalServer = express();
const trustProxy = config.getRawConfig().server.trust_proxy;
if (trustProxy) {
internalServer.set("trust proxy", trustProxy);
}
internalServer.use(helmet()); internalServer.use(helmet());
internalServer.use(cors()); internalServer.use(cors());
internalServer.use(stripDuplicateSesions); internalServer.use(stripDuplicateSesions);

View File

@@ -230,7 +230,7 @@ export class UsageService {
const orgIdToUse = await this.getBillingOrg(orgId); const orgIdToUse = await this.getBillingOrg(orgId);
const cacheKey = `customer_${orgIdToUse}_${featureId}`; const cacheKey = `customer_${orgIdToUse}_${featureId}`;
const cached = await cache.get<string>(cacheKey); const cached = cache.get<string>(cacheKey);
if (cached) { if (cached) {
return cached; return cached;
@@ -253,7 +253,7 @@ export class UsageService {
const customerId = customer.customerId; const customerId = customer.customerId;
// Cache the result // Cache the result
await cache.set(cacheKey, customerId, 300); // 5 minute TTL cache.set(cacheKey, customerId, 300); // 5 minute TTL
return customerId; return customerId;
} catch (error) { } catch (error) {

View File

@@ -11,7 +11,7 @@ import {
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne, inArray, or } from "drizzle-orm"; import { eq, and, ne, inArray } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
@@ -142,10 +142,7 @@ export async function updateClientResources(
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where( .where(
and( and(
or( inArray(users.username, resourceData.users),
inArray(users.username, resourceData.users),
inArray(users.email, resourceData.users)
),
eq(userOrgs.orgId, orgId) eq(userOrgs.orgId, orgId)
) )
); );
@@ -279,10 +276,7 @@ export async function updateClientResources(
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where( .where(
and( and(
or( inArray(users.username, resourceData.users),
inArray(users.username, resourceData.users),
inArray(users.email, resourceData.users)
),
eq(userOrgs.orgId, orgId) eq(userOrgs.orgId, orgId)
) )
); );

View File

@@ -212,10 +212,7 @@ export async function updateProxyResources(
} else { } else {
// Update existing resource // Update existing resource
const isLicensed = await isLicensedOrSubscribed( const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
orgId,
tierMatrix.maintencePage
);
if (!isLicensed) { if (!isLicensed) {
resourceData.maintenance = undefined; resourceData.maintenance = undefined;
} }
@@ -593,10 +590,7 @@ export async function updateProxyResources(
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(rule.match.toUpperCase(), rule.value) ||
rule.match.toUpperCase(),
rule.value
) ||
existingRule.priority !== intendedPriority existingRule.priority !== intendedPriority
) { ) {
validateRule(rule); validateRule(rule);
@@ -654,10 +648,7 @@ export async function updateProxyResources(
); );
} }
const isLicensed = await isLicensedOrSubscribed( const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
orgId,
tierMatrix.maintencePage
);
if (!isLicensed) { if (!isLicensed) {
resourceData.maintenance = undefined; resourceData.maintenance = undefined;
} }
@@ -944,12 +935,7 @@ async function syncUserResources(
.select() .select()
.from(users) .from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where( .where(and(eq(users.username, username), eq(userOrgs.orgId, orgId)))
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1); .limit(1);
if (!user) { if (!user) {

View File

@@ -69,7 +69,7 @@ export const AuthSchema = z.object({
.refine((roles) => !roles.includes("Admin"), { .refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in sso-roles" error: "Admin role cannot be included in sso-roles"
}), }),
"sso-users": z.array(z.string()).optional().default([]), "sso-users": z.array(z.email()).optional().default([]),
"whitelist-users": z.array(z.email()).optional().default([]), "whitelist-users": z.array(z.email()).optional().default([]),
"auto-login-idp": z.int().positive().optional() "auto-login-idp": z.int().positive().optional()
}); });
@@ -335,7 +335,7 @@ export const ClientResourceSchema = z
.refine((roles) => !roles.includes("Admin"), { .refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in roles" error: "Admin role cannot be included in roles"
}), }),
users: z.array(z.string()).optional().default([]), users: z.array(z.email()).optional().default([]),
machines: z.array(z.string()).optional().default([]) machines: z.array(z.string()).optional().default([])
}) })
.refine( .refine(

View File

@@ -1,10 +1,9 @@
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import logger from "@server/logger"; import logger from "@server/logger";
import { redisManager } from "@server/private/lib/redis";
// Create local cache with maxKeys limit to prevent memory leaks // Create cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient // With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
export const localCache = new NodeCache({ export const cache = new NodeCache({
stdTTL: 3600, stdTTL: 3600,
checkperiod: 120, checkperiod: 120,
maxKeys: 10000 maxKeys: 10000
@@ -12,255 +11,10 @@ export const localCache = new NodeCache({
// Log cache statistics periodically for monitoring // Log cache statistics periodically for monitoring
setInterval(() => { setInterval(() => {
const stats = localCache.getStats(); const stats = cache.getStats();
logger.debug( logger.debug(
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` `Cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
); );
}, 300000); // Every 5 minutes }, 300000); // Every 5 minutes
/**
* Adaptive cache that uses Redis when available in multi-node environments,
* otherwise falls back to local memory cache for single-node deployments.
*/
class AdaptiveCache {
private useRedis(): boolean {
return redisManager.isRedisEnabled() && redisManager.getHealthStatus().isHealthy;
}
/**
* Set a value in the cache
* @param key - Cache key
* @param value - Value to cache (will be JSON stringified for Redis)
* @param ttl - Time to live in seconds (0 = no expiration)
* @returns boolean indicating success
*/
async set(key: string, value: any, ttl?: number): Promise<boolean> {
const effectiveTtl = ttl === 0 ? undefined : ttl;
if (this.useRedis()) {
try {
const serialized = JSON.stringify(value);
const success = await redisManager.set(key, serialized, effectiveTtl);
if (success) {
logger.debug(`Set key in Redis: ${key}`);
return true;
}
// Redis failed, fall through to local cache
logger.debug(`Redis set failed for key ${key}, falling back to local cache`);
} catch (error) {
logger.error(`Redis set error for key ${key}:`, error);
// Fall through to local cache
}
}
// Use local cache as fallback or primary
const success = localCache.set(key, value, effectiveTtl || 0);
if (success) {
logger.debug(`Set key in local cache: ${key}`);
}
return success;
}
/**
* Get a value from the cache
* @param key - Cache key
* @returns The cached value or undefined if not found
*/
async get<T = any>(key: string): Promise<T | undefined> {
if (this.useRedis()) {
try {
const value = await redisManager.get(key);
if (value !== null) {
logger.debug(`Cache hit in Redis: ${key}`);
return JSON.parse(value) as T;
}
logger.debug(`Cache miss in Redis: ${key}`);
return undefined;
} catch (error) {
logger.error(`Redis get error for key ${key}:`, error);
// Fall through to local cache
}
}
// Use local cache as fallback or primary
const value = localCache.get<T>(key);
if (value !== undefined) {
logger.debug(`Cache hit in local cache: ${key}`);
} else {
logger.debug(`Cache miss in local cache: ${key}`);
}
return value;
}
/**
* Delete a value from the cache
* @param key - Cache key or array of keys
* @returns Number of deleted entries
*/
async del(key: string | string[]): Promise<number> {
const keys = Array.isArray(key) ? key : [key];
let deletedCount = 0;
if (this.useRedis()) {
try {
for (const k of keys) {
const success = await redisManager.del(k);
if (success) {
deletedCount++;
logger.debug(`Deleted key from Redis: ${k}`);
}
}
if (deletedCount === keys.length) {
return deletedCount;
}
// Some Redis deletes failed, fall through to local cache
logger.debug(`Some Redis deletes failed, falling back to local cache`);
} catch (error) {
logger.error(`Redis del error for keys ${keys.join(", ")}:`, error);
// Fall through to local cache
deletedCount = 0;
}
}
// Use local cache as fallback or primary
for (const k of keys) {
const success = localCache.del(k);
if (success > 0) {
deletedCount++;
logger.debug(`Deleted key from local cache: ${k}`);
}
}
return deletedCount;
}
/**
* Check if a key exists in the cache
* @param key - Cache key
* @returns boolean indicating if key exists
*/
async has(key: string): Promise<boolean> {
if (this.useRedis()) {
try {
const value = await redisManager.get(key);
return value !== null;
} catch (error) {
logger.error(`Redis has error for key ${key}:`, error);
// Fall through to local cache
}
}
// Use local cache as fallback or primary
return localCache.has(key);
}
/**
* Get multiple values from the cache
* @param keys - Array of cache keys
* @returns Array of values (undefined for missing keys)
*/
async mget<T = any>(keys: string[]): Promise<(T | undefined)[]> {
if (this.useRedis()) {
try {
const results: (T | undefined)[] = [];
for (const key of keys) {
const value = await redisManager.get(key);
if (value !== null) {
results.push(JSON.parse(value) as T);
} else {
results.push(undefined);
}
}
return results;
} catch (error) {
logger.error(`Redis mget error:`, error);
// Fall through to local cache
}
}
// Use local cache as fallback or primary
return keys.map((key) => localCache.get<T>(key));
}
/**
* Flush all keys from the cache
*/
async flushAll(): Promise<void> {
if (this.useRedis()) {
logger.warn("Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed");
}
localCache.flushAll();
logger.debug("Flushed local cache");
}
/**
* Get cache statistics
* Note: Only returns local cache stats, Redis stats are not included
*/
getStats() {
return localCache.getStats();
}
/**
* Get the current cache backend being used
* @returns "redis" if Redis is available and healthy, "local" otherwise
*/
getCurrentBackend(): "redis" | "local" {
return this.useRedis() ? "redis" : "local";
}
/**
* Take a key from the cache and delete it
* @param key - Cache key
* @returns The value or undefined if not found
*/
async take<T = any>(key: string): Promise<T | undefined> {
const value = await this.get<T>(key);
if (value !== undefined) {
await this.del(key);
}
return value;
}
/**
* Get TTL (time to live) for a key
* @param key - Cache key
* @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist
*/
getTtl(key: string): number {
// Note: This only works for local cache, Redis TTL is not supported
if (this.useRedis()) {
logger.warn(`getTtl called for key ${key} but Redis TTL lookup is not implemented`);
}
const ttl = localCache.getTtl(key);
if (ttl === undefined) {
return -1;
}
return Math.max(0, Math.floor((ttl - Date.now()) / 1000));
}
/**
* Get all keys from the cache
* Note: Only returns local cache keys, Redis keys are not included
*/
keys(): string[] {
if (this.useRedis()) {
logger.warn("keys() called but Redis keys are not included, only local cache keys returned");
}
return localCache.keys();
}
}
// Export singleton instance
export const cache = new AdaptiveCache();
export default cache; export default cache;

View File

@@ -10,6 +10,7 @@ import {
roles, roles,
Transaction, Transaction,
userClients, userClients,
userOrgRoles,
userOrgs userOrgs
} from "@server/db"; } from "@server/db";
import { getUniqueClientName } from "@server/db/names"; import { getUniqueClientName } from "@server/db/names";
@@ -39,20 +40,36 @@ export async function calculateUserClientsForOrgs(
return; return;
} }
// Get all user orgs // Get all user orgs with all roles (for org list and role-based logic)
const allUserOrgs = await transaction const userOrgRoleRows = await transaction
.select() .select()
.from(userOrgs) .from(userOrgs)
.innerJoin(roles, eq(roles.roleId, userOrgs.roleId)) .innerJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId)); .where(eq(userOrgs.userId, userId));
const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId); const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))];
const orgIdToRoleRows = new Map<
string,
(typeof userOrgRoleRows)[0][]
>();
for (const r of userOrgRoleRows) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
// For each OLM, ensure there's a client in each org the user is in // For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) { for (const olm of userOlms) {
for (const userRoleOrg of allUserOrgs) { for (const orgId of orgIdToRoleRows.keys()) {
const { userOrgs: userOrg, roles: role } = userRoleOrg; const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const orgId = userOrg.orgId; const userOrg = roleRowsForOrg[0].userOrgs;
const [org] = await transaction const [org] = await transaction
.select() .select()
@@ -196,7 +213,7 @@ export async function calculateUserClientsForOrgs(
const requireApproval = const requireApproval =
build !== "oss" && build !== "oss" &&
isOrgLicensed && isOrgLicensed &&
role.requireDeviceApproval; roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
const newClientData: InferInsertModel<typeof clients> = { const newClientData: InferInsertModel<typeof clients> = {
userId, userId,

View File

@@ -189,46 +189,6 @@ export const configSchema = z
.prefault({}) .prefault({})
}) })
.optional(), .optional(),
postgres_logs: z
.object({
connection_string: z
.string()
.optional()
.transform(getEnvOrYaml("POSTGRES_LOGS_CONNECTION_STRING")),
replicas: z
.array(
z.object({
connection_string: z.string()
})
)
.optional(),
pool: z
.object({
max_connections: z
.number()
.positive()
.optional()
.default(20),
max_replica_connections: z
.number()
.positive()
.optional()
.default(10),
idle_timeout_ms: z
.number()
.positive()
.optional()
.default(30000),
connection_timeout_ms: z
.number()
.positive()
.optional()
.default(5000)
})
.optional()
.prefault({})
})
.optional(),
traefik: z traefik: z
.object({ .object({
http_entrypoint: z.string().optional().default("web"), http_entrypoint: z.string().optional().default("web"),

View File

@@ -14,6 +14,7 @@ import {
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
userOrgRoles,
userOrgs, userOrgs,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
@@ -77,10 +78,10 @@ export async function getClientSiteResourceAccess(
// get all of the users in these roles // get all of the users in these roles
const userIdsFromRoles = await trx const userIdsFromRoles = await trx
.select({ .select({
userId: userOrgs.userId userId: userOrgRoles.userId
}) })
.from(userOrgs) .from(userOrgRoles)
.where(inArray(userOrgs.roleId, roleIds)) .where(inArray(userOrgRoles.roleId, roleIds))
.then((rows) => rows.map((row) => row.userId)); .then((rows) => rows.map((row) => row.userId));
const newAllUserIds = Array.from( const newAllUserIds = Array.from(
@@ -811,12 +812,12 @@ export async function rebuildClientAssociationsFromClient(
// Role-based access // Role-based access
const roleIds = await trx const roleIds = await trx
.select({ roleId: userOrgs.roleId }) .select({ roleId: userOrgRoles.roleId })
.from(userOrgs) .from(userOrgRoles)
.where( .where(
and( and(
eq(userOrgs.userId, client.userId), eq(userOrgRoles.userId, client.userId),
eq(userOrgs.orgId, client.orgId) eq(userOrgRoles.orgId, client.orgId)
) )
) // this needs to be locked onto this org or else cross-org access could happen ) // this needs to be locked onto this org or else cross-org access could happen
.then((rows) => rows.map((row) => row.roleId)); .then((rows) => rows.map((row) => row.roleId));

View File

@@ -6,7 +6,7 @@ import {
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
UserOrg, userOrgRoles,
userOrgs, userOrgs,
userResources, userResources,
userSiteResources, userSiteResources,
@@ -19,9 +19,15 @@ import { FeatureId } from "@server/lib/billing";
export async function assignUserToOrg( export async function assignUserToOrg(
org: Org, org: Org,
values: typeof userOrgs.$inferInsert, values: typeof userOrgs.$inferInsert,
roleId: number,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
) { ) {
const [userOrg] = await trx.insert(userOrgs).values(values).returning(); const [userOrg] = await trx.insert(userOrgs).values(values).returning();
await trx.insert(userOrgRoles).values({
userId: userOrg.userId,
orgId: userOrg.orgId,
roleId
});
// calculate if the user is in any other of the orgs before we count it as an add to the billing org // calculate if the user is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) { if (org.billingOrgId) {
@@ -58,6 +64,14 @@ export async function removeUserFromOrg(
userId: string, userId: string,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
) { ) {
await trx
.delete(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, org.orgId)
)
);
await trx await trx
.delete(userOrgs) .delete(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));

View File

@@ -0,0 +1,22 @@
import { db, userOrgRoles } from "@server/db";
import { and, eq } from "drizzle-orm";
/**
* Get all role IDs a user has in an organization.
* Returns empty array if the user has no roles in the org (callers must treat as no access).
*/
export async function getUserOrgRoleIds(
userId: string,
orgId: string
): Promise<number[]> {
const rows = await db
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
);
return rows.map((r) => r.roleId);
}

View File

@@ -21,8 +21,7 @@ export async function getUserOrgs(
try { try {
const userOrganizations = await db const userOrganizations = await db
.select({ .select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId
roleId: userOrgs.roleId
}) })
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.userId, userId)); .where(eq(userOrgs.userId, userId));

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyAccessTokenAccess( export async function verifyAccessTokenAccess(
req: Request, req: Request,
@@ -93,7 +94,10 @@ export async function verifyAccessTokenAccess(
) )
); );
} else { } else {
req.userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
resource[0].orgId!
);
req.userOrgId = resource[0].orgId!; req.userOrgId = resource[0].orgId!;
} }
@@ -118,7 +122,7 @@ export async function verifyAccessTokenAccess(
const resourceAllowed = await canUserAccessResource({ const resourceAllowed = await canUserAccessResource({
userId, userId,
resourceId, resourceId,
roleId: req.userOrgRoleId! roleIds: req.userOrgRoleIds ?? []
}); });
if (!resourceAllowed) { if (!resourceAllowed) {

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { roles, userOrgs } from "@server/db"; import { roles, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyAdmin( export async function verifyAdmin(
req: Request, req: Request,
@@ -62,13 +63,29 @@ export async function verifyAdmin(
} }
} }
const userRole = await db req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId!);
if (req.userOrgRoleIds.length === 0) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have Admin access"
)
);
}
const userAdminRoles = await db
.select() .select()
.from(roles) .from(roles)
.where(eq(roles.roleId, req.userOrg.roleId)) .where(
and(
inArray(roles.roleId, req.userOrgRoleIds),
eq(roles.isAdmin, true)
)
)
.limit(1); .limit(1);
if (userRole.length === 0 || !userRole[0].isAdmin) { if (userAdminRoles.length === 0) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { userOrgs, apiKeys, apiKeyOrg } from "@server/db"; import { userOrgs, apiKeys, apiKeyOrg } from "@server/db";
import { and, eq, or } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyApiKeyAccess( export async function verifyApiKeyAccess(
req: Request, req: Request,
@@ -103,8 +104,10 @@ export async function verifyApiKeyAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
orgId
);
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -1,11 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { Client, db } from "@server/db"; import { Client, db } from "@server/db";
import { userOrgs, clients, roleClients, userClients } from "@server/db"; import { userOrgs, clients, roleClients, userClients } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import logger from "@server/logger"; import logger from "@server/logger";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyClientAccess( export async function verifyClientAccess(
req: Request, req: Request,
@@ -113,21 +114,30 @@ export async function verifyClientAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
client.orgId
);
req.userOrgId = client.orgId; req.userOrgId = client.orgId;
// Check role-based site access first // Check role-based client access (any of user's roles)
const [roleClientAccess] = await db const roleClientAccessList =
.select() (req.userOrgRoleIds?.length ?? 0) > 0
.from(roleClients) ? await db
.where( .select()
and( .from(roleClients)
eq(roleClients.clientId, client.clientId), .where(
eq(roleClients.roleId, userOrgRoleId) and(
) eq(roleClients.clientId, client.clientId),
) inArray(
.limit(1); roleClients.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
const [roleClientAccess] = roleClientAccessList;
if (roleClientAccess) { if (roleClientAccess) {
// User has access to the site through their role // User has access to the site through their role

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, domains, orgDomains } from "@server/db"; import { db, domains, orgDomains } from "@server/db";
import { userOrgs, apiKeyOrg } from "@server/db"; import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyDomainAccess( export async function verifyDomainAccess(
req: Request, req: Request,
@@ -63,7 +64,7 @@ export async function verifyDomainAccess(
.where( .where(
and( and(
eq(userOrgs.userId, userId), eq(userOrgs.userId, userId),
eq(userOrgs.orgId, apiKeyOrg.orgId) eq(userOrgs.orgId, orgId)
) )
) )
.limit(1); .limit(1);
@@ -97,8 +98,7 @@ export async function verifyDomainAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgRoleId = userOrgRoleId;
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, orgs } from "@server/db"; import { db } from "@server/db";
import { userOrgs } from "@server/db"; import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyOrgAccess( export async function verifyOrgAccess(
req: Request, req: Request,
@@ -64,8 +65,8 @@ export async function verifyOrgAccess(
} }
} }
// User has access, attach the user's role to the request for potential future use // User has access, attach the user's role(s) to the request for potential future use
req.userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgId = orgId; req.userOrgId = orgId;
return next(); return next();

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, Resource } from "@server/db"; import { db, Resource } from "@server/db";
import { resources, userOrgs, userResources, roleResources } from "@server/db"; import { resources, userOrgs, userResources, roleResources } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyResourceAccess( export async function verifyResourceAccess(
req: Request, req: Request,
@@ -107,20 +108,28 @@ export async function verifyResourceAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
resource.orgId
);
req.userOrgId = resource.orgId; req.userOrgId = resource.orgId;
const roleResourceAccess = await db const roleResourceAccess =
.select() (req.userOrgRoleIds?.length ?? 0) > 0
.from(roleResources) ? await db
.where( .select()
and( .from(roleResources)
eq(roleResources.resourceId, resource.resourceId), .where(
eq(roleResources.roleId, userOrgRoleId) and(
) eq(roleResources.resourceId, resource.resourceId),
) inArray(
.limit(1); roleResources.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess.length > 0) {
return next(); return next();

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger"; import logger from "@server/logger";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyRoleAccess( export async function verifyRoleAccess(
req: Request, req: Request,
@@ -99,7 +100,6 @@ export async function verifyRoleAccess(
} }
if (!req.userOrg) { if (!req.userOrg) {
// get the userORg
const userOrg = await db const userOrg = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
@@ -109,7 +109,7 @@ export async function verifyRoleAccess(
.limit(1); .limit(1);
req.userOrg = userOrg[0]; req.userOrg = userOrg[0];
req.userOrgRoleId = userOrg[0].roleId; req.userOrgRoleIds = await getUserOrgRoleIds(userId, orgId!);
} }
if (!req.userOrg) { if (!req.userOrg) {

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db"; import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db";
import { and, eq, or } from "drizzle-orm"; import { and, eq, inArray, or } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifySiteAccess( export async function verifySiteAccess(
req: Request, req: Request,
@@ -112,21 +113,29 @@ export async function verifySiteAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
site.orgId
);
req.userOrgId = site.orgId; req.userOrgId = site.orgId;
// Check role-based site access first // Check role-based site access first (any of user's roles)
const roleSiteAccess = await db const roleSiteAccess =
.select() (req.userOrgRoleIds?.length ?? 0) > 0
.from(roleSites) ? await db
.where( .select()
and( .from(roleSites)
eq(roleSites.siteId, site.siteId), .where(
eq(roleSites.roleId, userOrgRoleId) and(
) eq(roleSites.siteId, site.siteId),
) inArray(
.limit(1); roleSites.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
if (roleSiteAccess.length > 0) { if (roleSiteAccess.length > 0) {
// User's role has access to the site // User's role has access to the site

View File

@@ -1,11 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db";
import { siteResources } from "@server/db"; import { siteResources } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger"; import logger from "@server/logger";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifySiteResourceAccess( export async function verifySiteResourceAccess(
req: Request, req: Request,
@@ -109,23 +110,34 @@ export async function verifySiteResourceAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
siteResource.orgId
);
req.userOrgId = siteResource.orgId; req.userOrgId = siteResource.orgId;
// Attach the siteResource to the request for use in the next middleware/route // Attach the siteResource to the request for use in the next middleware/route
req.siteResource = siteResource; req.siteResource = siteResource;
const roleResourceAccess = await db const roleResourceAccess =
.select() (req.userOrgRoleIds?.length ?? 0) > 0
.from(roleSiteResources) ? await db
.where( .select()
and( .from(roleSiteResources)
eq(roleSiteResources.siteResourceId, siteResourceIdNum), .where(
eq(roleSiteResources.roleId, userOrgRoleId) and(
) eq(
) roleSiteResources.siteResourceId,
.limit(1); siteResourceIdNum
),
inArray(
roleSiteResources.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess.length > 0) {
return next(); return next();

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../auth/canUserAccessResource"; import { canUserAccessResource } from "../auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyTargetAccess( export async function verifyTargetAccess(
req: Request, req: Request,
@@ -99,7 +100,10 @@ export async function verifyTargetAccess(
) )
); );
} else { } else {
req.userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
resource[0].orgId!
);
req.userOrgId = resource[0].orgId!; req.userOrgId = resource[0].orgId!;
} }
@@ -126,7 +130,7 @@ export async function verifyTargetAccess(
const resourceAllowed = await canUserAccessResource({ const resourceAllowed = await canUserAccessResource({
userId, userId,
resourceId, resourceId,
roleId: req.userOrgRoleId! roleIds: req.userOrgRoleIds ?? []
}); });
if (!resourceAllowed) { if (!resourceAllowed) {

View File

@@ -12,7 +12,7 @@ export async function verifyUserInRole(
const roleId = parseInt( const roleId = parseInt(
req.params.roleId || req.body.roleId || req.query.roleId req.params.roleId || req.body.roleId || req.query.roleId
); );
const userRoleId = req.userOrgRoleId; const userOrgRoleIds = req.userOrgRoleIds ?? [];
if (isNaN(roleId)) { if (isNaN(roleId)) {
return next( return next(
@@ -20,7 +20,7 @@ export async function verifyUserInRole(
); );
} }
if (!userRoleId) { if (userOrgRoleIds.length === 0) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
@@ -29,7 +29,7 @@ export async function verifyUserInRole(
); );
} }
if (userRoleId !== roleId) { if (!userOrgRoleIds.includes(roleId)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,

View File

@@ -55,7 +55,7 @@ export async function getValidCertificatesForDomains(
if (useCache) { if (useCache) {
for (const domain of domains) { for (const domain of domains) {
const cacheKey = `cert:${domain}`; const cacheKey = `cert:${domain}`;
const cachedCert = await cache.get<CertificateResult>(cacheKey); const cachedCert = cache.get<CertificateResult>(cacheKey);
if (cachedCert) { if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit finalResults.push(cachedCert); // Valid cache hit
} else { } else {
@@ -169,7 +169,7 @@ export async function getValidCertificatesForDomains(
// Add to cache for future requests, using the *requested domain* as the key // Add to cache for future requests, using the *requested domain* as the key
if (useCache) { if (useCache) {
const cacheKey = `cert:${domain}`; const cacheKey = `cert:${domain}`;
await cache.set(cacheKey, resultCert, 180); cache.set(cacheKey, resultCert, 180);
} }
} }
} }

View File

@@ -11,7 +11,7 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { accessAuditLog, logsDb, db, orgs } from "@server/db"; import { accessAuditLog, db, orgs } from "@server/db";
import { getCountryCodeForIp } from "@server/lib/geoip"; import { getCountryCodeForIp } from "@server/lib/geoip";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq, lt } from "drizzle-orm"; import { and, eq, lt } from "drizzle-orm";
@@ -21,7 +21,7 @@ import { stripPortFromHost } from "@server/lib/ip";
async function getAccessDays(orgId: string): Promise<number> { async function getAccessDays(orgId: string): Promise<number> {
// check cache first // check cache first
const cached = await cache.get<number>(`org_${orgId}_accessDays`); const cached = cache.get<number>(`org_${orgId}_accessDays`);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
@@ -39,7 +39,7 @@ async function getAccessDays(orgId: string): Promise<number> {
} }
// store the result in cache // store the result in cache
await cache.set( cache.set(
`org_${orgId}_accessDays`, `org_${orgId}_accessDays`,
org.settingsLogRetentionDaysAction, org.settingsLogRetentionDaysAction,
300 300
@@ -52,7 +52,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
try { try {
await logsDb await db
.delete(accessAuditLog) .delete(accessAuditLog)
.where( .where(
and( and(
@@ -124,7 +124,7 @@ export async function logAccessAudit(data: {
? await getCountryCodeFromIp(data.requestIp) ? await getCountryCodeFromIp(data.requestIp)
: undefined; : undefined;
await logsDb.insert(accessAuditLog).values({ await db.insert(accessAuditLog).values({
timestamp: timestamp, timestamp: timestamp,
orgId: data.orgId, orgId: data.orgId,
actorType, actorType,
@@ -146,14 +146,14 @@ export async function logAccessAudit(data: {
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> { async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
const geoIpCacheKey = `geoip_access:${ip}`; const geoIpCacheKey = `geoip_access:${ip}`;
let cachedCountryCode: string | undefined = await cache.get(geoIpCacheKey); let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
if (!cachedCountryCode) { if (!cachedCountryCode) {
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
// Only cache successful lookups to avoid filling cache with undefined values // Only cache successful lookups to avoid filling cache with undefined values
if (cachedCountryCode) { if (cachedCountryCode) {
// Cache for longer since IP geolocation doesn't change frequently // Cache for longer since IP geolocation doesn't change frequently
await cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
} }
} }

View File

@@ -83,46 +83,6 @@ export const privateConfigSchema = z.object({
.optional() .optional()
}) })
.optional(), .optional(),
postgres_logs: z
.object({
connection_string: z
.string()
.optional()
.transform(getEnvOrYaml("POSTGRES_LOGS_CONNECTION_STRING")),
replicas: z
.array(
z.object({
connection_string: z.string()
})
)
.optional(),
pool: z
.object({
max_connections: z
.number()
.positive()
.optional()
.default(20),
max_replica_connections: z
.number()
.positive()
.optional()
.default(10),
idle_timeout_ms: z
.number()
.positive()
.optional()
.default(30000),
connection_timeout_ms: z
.number()
.positive()
.optional()
.default(5000)
})
.optional()
.prefault({})
})
.optional(),
gerbil: z gerbil: z
.object({ .object({
local_exit_node_reachable_at: z local_exit_node_reachable_at: z

View File

@@ -12,7 +12,7 @@
*/ */
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { actionAuditLog, logsDb, db, orgs } from "@server/db"; import { actionAuditLog, db, orgs } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
@@ -23,7 +23,7 @@ import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
async function getActionDays(orgId: string): Promise<number> { async function getActionDays(orgId: string): Promise<number> {
// check cache first // check cache first
const cached = await cache.get<number>(`org_${orgId}_actionDays`); const cached = cache.get<number>(`org_${orgId}_actionDays`);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
@@ -41,7 +41,7 @@ async function getActionDays(orgId: string): Promise<number> {
} }
// store the result in cache // store the result in cache
await cache.set( cache.set(
`org_${orgId}_actionDays`, `org_${orgId}_actionDays`,
org.settingsLogRetentionDaysAction, org.settingsLogRetentionDaysAction,
300 300
@@ -54,7 +54,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
try { try {
await logsDb await db
.delete(actionAuditLog) .delete(actionAuditLog)
.where( .where(
and( and(
@@ -123,7 +123,7 @@ export function logActionAudit(action: ActionsEnum) {
metadata = JSON.stringify(req.params); metadata = JSON.stringify(req.params);
} }
await logsDb.insert(actionAuditLog).values({ await db.insert(actionAuditLog).values({
timestamp, timestamp,
orgId, orgId,
actorType, actorType,

View File

@@ -13,9 +13,10 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { userOrgs, db, idp, idpOrg } from "@server/db"; import { userOrgs, db, idp, idpOrg } from "@server/db";
import { and, eq, or } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyIdpAccess( export async function verifyIdpAccess(
req: Request, req: Request,
@@ -84,8 +85,10 @@ export async function verifyIdpAccess(
); );
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
idpRes.idpOrg.orgId
);
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -12,11 +12,12 @@
*/ */
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db"; import { db, exitNodeOrgs, remoteExitNodes } from "@server/db";
import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; import { userOrgs } from "@server/db";
import { and, eq, or } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyRemoteExitNodeAccess( export async function verifyRemoteExitNodeAccess(
req: Request, req: Request,
@@ -103,8 +104,10 @@ export async function verifyRemoteExitNodeAccess(
); );
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
exitNodeOrg.orgId
);
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -11,11 +11,11 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { accessAuditLog, logsDb, resources, db, primaryDb } from "@server/db"; import { accessAuditLog, db, resources } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; import { eq, gt, lt, and, count, desc } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { z } from "zod"; import { z } from "zod";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -115,13 +115,15 @@ function getWhere(data: Q) {
} }
export function queryAccess(data: Q) { export function queryAccess(data: Q) {
return logsDb return db
.select({ .select({
orgId: accessAuditLog.orgId, orgId: accessAuditLog.orgId,
action: accessAuditLog.action, action: accessAuditLog.action,
actorType: accessAuditLog.actorType, actorType: accessAuditLog.actorType,
actorId: accessAuditLog.actorId, actorId: accessAuditLog.actorId,
resourceId: accessAuditLog.resourceId, resourceId: accessAuditLog.resourceId,
resourceName: resources.name,
resourceNiceId: resources.niceId,
ip: accessAuditLog.ip, ip: accessAuditLog.ip,
location: accessAuditLog.location, location: accessAuditLog.location,
userAgent: accessAuditLog.userAgent, userAgent: accessAuditLog.userAgent,
@@ -131,46 +133,16 @@ export function queryAccess(data: Q) {
actor: accessAuditLog.actor actor: accessAuditLog.actor
}) })
.from(accessAuditLog) .from(accessAuditLog)
.leftJoin(
resources,
eq(accessAuditLog.resourceId, resources.resourceId)
)
.where(getWhere(data)) .where(getWhere(data))
.orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id)); .orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id));
} }
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAccess>>) {
// If logs database is the same as main database, we can do a join
// Otherwise, we need to fetch resource details separately
const resourceIds = logs
.map(log => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
}
// Fetch resource details from main database
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
// Create a map for quick lookup
const resourceMap = new Map(
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
);
// Enrich logs with resource details
return logs.map(log => ({
...log,
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
}));
}
export function countAccessQuery(data: Q) { export function countAccessQuery(data: Q) {
const countQuery = logsDb const countQuery = db
.select({ count: count() }) .select({ count: count() })
.from(accessAuditLog) .from(accessAuditLog)
.where(getWhere(data)); .where(getWhere(data));
@@ -189,7 +161,7 @@ async function queryUniqueFilterAttributes(
); );
// Get unique actors // Get unique actors
const uniqueActors = await logsDb const uniqueActors = await db
.selectDistinct({ .selectDistinct({
actor: accessAuditLog.actor actor: accessAuditLog.actor
}) })
@@ -197,7 +169,7 @@ async function queryUniqueFilterAttributes(
.where(baseConditions); .where(baseConditions);
// Get unique locations // Get unique locations
const uniqueLocations = await logsDb const uniqueLocations = await db
.selectDistinct({ .selectDistinct({
locations: accessAuditLog.location locations: accessAuditLog.location
}) })
@@ -205,40 +177,25 @@ async function queryUniqueFilterAttributes(
.where(baseConditions); .where(baseConditions);
// Get unique resources with names // Get unique resources with names
const uniqueResources = await logsDb const uniqueResources = await db
.selectDistinct({ .selectDistinct({
id: accessAuditLog.resourceId id: accessAuditLog.resourceId,
name: resources.name
}) })
.from(accessAuditLog) .from(accessAuditLog)
.leftJoin(
resources,
eq(accessAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions); .where(baseConditions);
// Fetch resource names from main database for the unique resource IDs
const resourceIds = uniqueResources
.map(row => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = resourceDetails.map(r => ({
id: r.resourceId,
name: r.name
}));
}
return { return {
actors: uniqueActors actors: uniqueActors
.map((row) => row.actor) .map((row) => row.actor)
.filter((actor): actor is string => actor !== null), .filter((actor): actor is string => actor !== null),
resources: resourcesWithNames, resources: uniqueResources.filter(
(row): row is { id: number; name: string | null } => row.id !== null
),
locations: uniqueLocations locations: uniqueLocations
.map((row) => row.locations) .map((row) => row.locations)
.filter((location): location is string => location !== null) .filter((location): location is string => location !== null)
@@ -286,10 +243,7 @@ export async function queryAccessAuditLogs(
const baseQuery = queryAccess(data); const baseQuery = queryAccess(data);
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset); const log = await baseQuery.limit(data.limit).offset(data.offset);
// Enrich with resource details (handles cross-database scenario)
const log = await enrichWithResourceDetails(logsRaw);
const totalCountResult = await countAccessQuery(data); const totalCountResult = await countAccessQuery(data);
const totalCount = totalCountResult[0].count; const totalCount = totalCountResult[0].count;

View File

@@ -11,7 +11,7 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { actionAuditLog, logsDb } from "@server/db"; import { actionAuditLog, db } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
@@ -97,7 +97,7 @@ function getWhere(data: Q) {
} }
export function queryAction(data: Q) { export function queryAction(data: Q) {
return logsDb return db
.select({ .select({
orgId: actionAuditLog.orgId, orgId: actionAuditLog.orgId,
action: actionAuditLog.action, action: actionAuditLog.action,
@@ -113,7 +113,7 @@ export function queryAction(data: Q) {
} }
export function countActionQuery(data: Q) { export function countActionQuery(data: Q) {
const countQuery = logsDb const countQuery = db
.select({ count: count() }) .select({ count: count() })
.from(actionAuditLog) .from(actionAuditLog)
.where(getWhere(data)); .where(getWhere(data));
@@ -132,14 +132,14 @@ async function queryUniqueFilterAttributes(
); );
// Get unique actors // Get unique actors
const uniqueActors = await logsDb const uniqueActors = await db
.selectDistinct({ .selectDistinct({
actor: actionAuditLog.actor actor: actionAuditLog.actor
}) })
.from(actionAuditLog) .from(actionAuditLog)
.where(baseConditions); .where(baseConditions);
const uniqueActions = await logsDb const uniqueActions = await db
.selectDistinct({ .selectDistinct({
action: actionAuditLog.action action: actionAuditLog.action
}) })

View File

@@ -480,9 +480,9 @@ authenticated.get(
authenticated.post( authenticated.post(
"/re-key/:clientId/regenerate-client-secret", "/re-key/:clientId/regenerate-client-secret",
verifyClientAccess, // this is first to set the org id
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials), verifyValidSubscription(tierMatrix.rotateCredentials),
verifyClientAccess, // this is first to set the org id
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.reGenerateSecret), verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateClientSecret reKey.reGenerateClientSecret
@@ -490,9 +490,9 @@ authenticated.post(
authenticated.post( authenticated.post(
"/re-key/:siteId/regenerate-site-secret", "/re-key/:siteId/regenerate-site-secret",
verifySiteAccess, // this is first to set the org id
verifyValidLicense, verifyValidLicense,
verifyValidSubscription(tierMatrix.rotateCredentials), verifyValidSubscription(tierMatrix.rotateCredentials),
verifySiteAccess, // this is first to set the org id
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.reGenerateSecret), verifyUserHasAction(ActionsEnum.reGenerateSecret),
reKey.reGenerateSiteSecret reKey.reGenerateSiteSecret

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { userOrgs, users, roles, orgs } from "@server/db"; import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db";
import { eq, and, or } from "drizzle-orm"; import { eq, and, or } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -95,7 +95,14 @@ async function getOrgAdmins(orgId: string) {
}) })
.from(userOrgs) .from(userOrgs)
.innerJoin(users, eq(userOrgs.userId, users.userId)) .innerJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where( .where(
and( and(
eq(userOrgs.orgId, orgId), eq(userOrgs.orgId, orgId),
@@ -103,8 +110,11 @@ async function getOrgAdmins(orgId: string) {
) )
); );
// Filter to only include users with verified emails // Dedupe by userId (user may have multiple roles)
const orgAdmins = admins.filter( const byUserId = new Map(
admins.map((a) => [a.userId, a])
);
const orgAdmins = Array.from(byUserId.values()).filter(
(admin) => admin.email && admin.email.length > 0 (admin) => admin.email && admin.email.length > 0
); );

View File

@@ -79,7 +79,7 @@ export async function createRemoteExitNode(
const { remoteExitNodeId, secret } = parsedBody.data; const { remoteExitNodeId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );

View File

@@ -30,7 +30,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { eq, or, and } from "drizzle-orm"; import { and, eq, inArray, or } from "drizzle-orm";
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA"; import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -122,7 +122,7 @@ export async function signSshKey(
resource: resourceQueryString resource: resourceQueryString
} = parsedBody.data; } = parsedBody.data;
const userId = req.user?.userId; const userId = req.user?.userId;
const roleId = req.userOrgRoleId!; const roleIds = req.userOrgRoleIds ?? [];
if (!userId) { if (!userId) {
return next( return next(
@@ -130,6 +130,15 @@ export async function signSshKey(
); );
} }
if (roleIds.length === 0) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User has no role in organization"
)
);
}
const [userOrg] = await db const [userOrg] = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
@@ -310,11 +319,11 @@ export async function signSshKey(
); );
} }
// Check if the user has access to the resource // Check if the user has access to the resource (any of their roles)
const hasAccess = await canUserAccessSiteResource({ const hasAccess = await canUserAccessSiteResource({
userId: userId, userId: userId,
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
roleId: roleId roleIds
}); });
if (!hasAccess) { if (!hasAccess) {
@@ -326,28 +335,39 @@ export async function signSshKey(
); );
} }
const [roleRow] = await db const roleRows = await db
.select() .select()
.from(roles) .from(roles)
.where(eq(roles.roleId, roleId)) .where(inArray(roles.roleId, roleIds));
.limit(1);
let parsedSudoCommands: string[] = []; const parsedSudoCommands: string[] = [];
let parsedGroups: string[] = []; const parsedGroupsSet = new Set<string>();
try { let homedir: boolean | null = null;
parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); const sudoModeOrder = { none: 0, commands: 1, all: 2 };
if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = []; let sudoMode: "none" | "commands" | "all" = "none";
} catch { for (const roleRow of roleRows) {
parsedSudoCommands = []; try {
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
} catch {
// skip
}
try {
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g));
} catch {
// skip
}
if (roleRow?.sshCreateHomeDir === true) homedir = true;
const m = roleRow?.sshSudoMode ?? "none";
if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) {
sudoMode = m as "none" | "commands" | "all";
}
} }
try { const parsedGroups = Array.from(parsedGroupsSet);
parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); if (homedir === null && roleRows.length > 0) {
if (!Array.isArray(parsedGroups)) parsedGroups = []; homedir = roleRows[0].sshCreateHomeDir ?? null;
} catch {
parsedGroups = [];
} }
const homedir = roleRow?.sshCreateHomeDir ?? null;
const sudoMode = roleRow?.sshSudoMode ?? "none";
// get the site // get the site
const [newt] = await db const [newt] = await db

View File

@@ -208,7 +208,7 @@ export async function listAccessTokens(
.where( .where(
or( or(
eq(userResources.userId, req.user!.userId), eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!) inArray(roleResources.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -1,4 +1,4 @@
import { logsDb, requestAuditLog, driver, primaryLogsDb } from "@server/db"; import { db, requestAuditLog, driver, primaryDb } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
@@ -74,12 +74,12 @@ async function query(query: Q) {
); );
} }
const [all] = await primaryLogsDb const [all] = await primaryDb
.select({ total: count() }) .select({ total: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions); .where(baseConditions);
const [blocked] = await primaryLogsDb const [blocked] = await primaryDb
.select({ total: count() }) .select({ total: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(and(baseConditions, eq(requestAuditLog.action, false))); .where(and(baseConditions, eq(requestAuditLog.action, false)));
@@ -90,7 +90,7 @@ async function query(query: Q) {
const DISTINCT_LIMIT = 500; const DISTINCT_LIMIT = 500;
const requestsPerCountry = await primaryLogsDb const requestsPerCountry = await primaryDb
.selectDistinct({ .selectDistinct({
code: requestAuditLog.location, code: requestAuditLog.location,
count: totalQ count: totalQ
@@ -118,7 +118,7 @@ async function query(query: Q) {
const booleanTrue = driver === "pg" ? sql`true` : sql`1`; const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
const booleanFalse = driver === "pg" ? sql`false` : sql`0`; const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
const requestsPerDay = await primaryLogsDb const requestsPerDay = await primaryDb
.select({ .select({
day: groupByDayFunction.as("day"), day: groupByDayFunction.as("day"),
allowedCount: allowedCount:

View File

@@ -1,8 +1,8 @@
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db"; import { db, primaryDb, requestAuditLog, resources } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; import { eq, gt, lt, and, count, desc } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { z } from "zod"; import { z } from "zod";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -107,7 +107,7 @@ function getWhere(data: Q) {
} }
export function queryRequest(data: Q) { export function queryRequest(data: Q) {
return primaryLogsDb return primaryDb
.select({ .select({
id: requestAuditLog.id, id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp, timestamp: requestAuditLog.timestamp,
@@ -129,49 +129,21 @@ export function queryRequest(data: Q) {
host: requestAuditLog.host, host: requestAuditLog.host,
path: requestAuditLog.path, path: requestAuditLog.path,
method: requestAuditLog.method, method: requestAuditLog.method,
tls: requestAuditLog.tls tls: requestAuditLog.tls,
resourceName: resources.name,
resourceNiceId: resources.niceId
}) })
.from(requestAuditLog) .from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
) // TODO: Is this efficient?
.where(getWhere(data)) .where(getWhere(data))
.orderBy(desc(requestAuditLog.timestamp)); .orderBy(desc(requestAuditLog.timestamp));
} }
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
// If logs database is the same as main database, we can do a join
// Otherwise, we need to fetch resource details separately
const resourceIds = logs
.map(log => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
}
// Fetch resource details from main database
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name,
niceId: resources.niceId
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
// Create a map for quick lookup
const resourceMap = new Map(
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
);
// Enrich logs with resource details
return logs.map(log => ({
...log,
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
}));
}
export function countRequestQuery(data: Q) { export function countRequestQuery(data: Q) {
const countQuery = primaryLogsDb const countQuery = primaryDb
.select({ count: count() }) .select({ count: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(getWhere(data)); .where(getWhere(data));
@@ -213,31 +185,36 @@ async function queryUniqueFilterAttributes(
uniquePaths, uniquePaths,
uniqueResources uniqueResources
] = await Promise.all([ ] = await Promise.all([
primaryLogsDb primaryDb
.selectDistinct({ actor: requestAuditLog.actor }) .selectDistinct({ actor: requestAuditLog.actor })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb primaryDb
.selectDistinct({ locations: requestAuditLog.location }) .selectDistinct({ locations: requestAuditLog.location })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb primaryDb
.selectDistinct({ hosts: requestAuditLog.host }) .selectDistinct({ hosts: requestAuditLog.host })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb primaryDb
.selectDistinct({ paths: requestAuditLog.path }) .selectDistinct({ paths: requestAuditLog.path })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1), .limit(DISTINCT_LIMIT + 1),
primaryLogsDb primaryDb
.selectDistinct({ .selectDistinct({
id: requestAuditLog.resourceId id: requestAuditLog.resourceId,
name: resources.name
}) })
.from(requestAuditLog) .from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1) .limit(DISTINCT_LIMIT + 1)
]); ]);
@@ -254,33 +231,13 @@ async function queryUniqueFilterAttributes(
// throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range."); // throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range.");
// } // }
// Fetch resource names from main database for the unique resource IDs
const resourceIds = uniqueResources
.map(row => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
resourceId: resources.resourceId,
name: resources.name
})
.from(resources)
.where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = resourceDetails.map(r => ({
id: r.resourceId,
name: r.name
}));
}
return { return {
actors: uniqueActors actors: uniqueActors
.map((row) => row.actor) .map((row) => row.actor)
.filter((actor): actor is string => actor !== null), .filter((actor): actor is string => actor !== null),
resources: resourcesWithNames, resources: uniqueResources.filter(
(row): row is { id: number; name: string | null } => row.id !== null
),
locations: uniqueLocations locations: uniqueLocations
.map((row) => row.locations) .map((row) => row.locations)
.filter((location): location is string => location !== null), .filter((location): location is string => location !== null),
@@ -323,10 +280,7 @@ export async function queryRequestAuditLogs(
const baseQuery = queryRequest(data); const baseQuery = queryRequest(data);
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset); const log = await baseQuery.limit(data.limit).offset(data.offset);
// Enrich with resource details (handles cross-database scenario)
const log = await enrichWithResourceDetails(logsRaw);
const totalCountResult = await countRequestQuery(data); const totalCountResult = await countRequestQuery(data);
const totalCount = totalCountResult[0].count; const totalCount = totalCountResult[0].count;

View File

@@ -1,4 +1,4 @@
import { logsDb, primaryLogsDb, db, orgs, requestAuditLog } from "@server/db"; import { db, orgs, requestAuditLog } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq, lt, sql } from "drizzle-orm"; import { and, eq, lt, sql } from "drizzle-orm";
import cache from "@server/lib/cache"; import cache from "@server/lib/cache";
@@ -69,7 +69,7 @@ async function flushAuditLogs() {
try { try {
// Use a transaction to ensure all inserts succeed or fail together // Use a transaction to ensure all inserts succeed or fail together
// This prevents index corruption from partial writes // This prevents index corruption from partial writes
await logsDb.transaction(async (tx) => { await db.transaction(async (tx) => {
// Batch insert logs in groups of 25 to avoid overwhelming the database // Batch insert logs in groups of 25 to avoid overwhelming the database
const BATCH_DB_SIZE = 25; const BATCH_DB_SIZE = 25;
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
@@ -130,7 +130,7 @@ export async function shutdownAuditLogger() {
async function getRetentionDays(orgId: string): Promise<number> { async function getRetentionDays(orgId: string): Promise<number> {
// check cache first // check cache first
const cached = await cache.get<number>(`org_${orgId}_retentionDays`); const cached = cache.get<number>(`org_${orgId}_retentionDays`);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
@@ -149,7 +149,7 @@ async function getRetentionDays(orgId: string): Promise<number> {
} }
// store the result in cache // store the result in cache
await cache.set( cache.set(
`org_${orgId}_retentionDays`, `org_${orgId}_retentionDays`,
org.settingsLogRetentionDaysRequest, org.settingsLogRetentionDaysRequest,
300 300
@@ -162,7 +162,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
try { try {
await logsDb await db
.delete(requestAuditLog) .delete(requestAuditLog)
.where( .where(
and( and(

View File

@@ -3,12 +3,13 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
import { import {
getResourceByDomain, getResourceByDomain,
getResourceRules, getResourceRules,
getRoleName,
getRoleResourceAccess, getRoleResourceAccess,
getUserOrgRole,
getUserResourceAccess, getUserResourceAccess,
getOrgLoginPage, getOrgLoginPage,
getUserSessionWithUser getUserSessionWithUser
} from "@server/db/queries/verifySessionQueries"; } from "@server/db/queries/verifySessionQueries";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { import {
LoginPage, LoginPage,
Org, Org,
@@ -37,7 +38,7 @@ import {
enforceResourceSessionLength enforceResourceSessionLength
} from "#dynamic/lib/checkOrgAccessPolicy"; } from "#dynamic/lib/checkOrgAccessPolicy";
import { logRequestAudit } from "./logRequestAudit"; import { logRequestAudit } from "./logRequestAudit";
import { localCache } from "@server/lib/cache"; import cache from "@server/lib/cache";
import { APP_VERSION } from "@server/lib/consts"; import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -137,7 +138,7 @@ export async function verifyResourceSession(
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org; org: Org;
} }
| undefined = localCache.get(resourceCacheKey); | undefined = cache.get(resourceCacheKey);
if (!resourceData) { if (!resourceData) {
const result = await getResourceByDomain(cleanHost); const result = await getResourceByDomain(cleanHost);
@@ -161,7 +162,7 @@ export async function verifyResourceSession(
} }
resourceData = result; resourceData = result;
localCache.set(resourceCacheKey, resourceData, 5); cache.set(resourceCacheKey, resourceData, 5);
} }
const { const {
@@ -405,7 +406,7 @@ export async function verifyResourceSession(
// check for HTTP Basic Auth header // check for HTTP Basic Auth header
const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`; const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`;
if (headerAuth && clientHeaderAuth) { if (headerAuth && clientHeaderAuth) {
if (localCache.get(clientHeaderAuthKey)) { if (cache.get(clientHeaderAuthKey)) {
logger.debug( logger.debug(
"Resource allowed because header auth is valid (cached)" "Resource allowed because header auth is valid (cached)"
); );
@@ -428,7 +429,7 @@ export async function verifyResourceSession(
headerAuth.headerAuthHash headerAuth.headerAuthHash
) )
) { ) {
localCache.set(clientHeaderAuthKey, clientHeaderAuth, 5); cache.set(clientHeaderAuthKey, clientHeaderAuth, 5);
logger.debug("Resource allowed because header auth is valid"); logger.debug("Resource allowed because header auth is valid");
logRequestAudit( logRequestAudit(
@@ -520,7 +521,7 @@ export async function verifyResourceSession(
if (resourceSessionToken) { if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`; const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = localCache.get(sessionCacheKey); let resourceSession: any = cache.get(sessionCacheKey);
if (!resourceSession) { if (!resourceSession) {
const result = await validateResourceSessionToken( const result = await validateResourceSessionToken(
@@ -529,7 +530,7 @@ export async function verifyResourceSession(
); );
resourceSession = result?.resourceSession; resourceSession = result?.resourceSession;
localCache.set(sessionCacheKey, resourceSession, 5); cache.set(sessionCacheKey, resourceSession, 5);
} }
if (resourceSession?.isRequestToken) { if (resourceSession?.isRequestToken) {
@@ -662,7 +663,7 @@ export async function verifyResourceSession(
}:${resource.resourceId}`; }:${resource.resourceId}`;
let allowedUserData: BasicUserData | null | undefined = let allowedUserData: BasicUserData | null | undefined =
localCache.get(userAccessCacheKey); cache.get(userAccessCacheKey);
if (allowedUserData === undefined) { if (allowedUserData === undefined) {
allowedUserData = await isUserAllowedToAccessResource( allowedUserData = await isUserAllowedToAccessResource(
@@ -671,7 +672,7 @@ export async function verifyResourceSession(
resourceData.org resourceData.org
); );
localCache.set(userAccessCacheKey, allowedUserData, 5); cache.set(userAccessCacheKey, allowedUserData, 5);
} }
if ( if (
@@ -916,9 +917,9 @@ async function isUserAllowedToAccessResource(
return null; return null;
} }
const userOrgRole = await getUserOrgRole(user.userId, resource.orgId); const userOrgRoleIds = await getUserOrgRoleIds(user.userId, resource.orgId);
if (!userOrgRole) { if (!userOrgRoleIds.length) {
return null; return null;
} }
@@ -934,17 +935,23 @@ async function isUserAllowedToAccessResource(
return null; return null;
} }
const roleResourceAccess = await getRoleResourceAccess( const roleNames: string[] = [];
resource.resourceId, for (const roleId of userOrgRoleIds) {
userOrgRole.roleId const roleResourceAccess = await getRoleResourceAccess(
); resource.resourceId,
roleId
if (roleResourceAccess) { );
if (roleResourceAccess) {
const roleName = await getRoleName(roleId);
if (roleName) roleNames.push(roleName);
}
}
if (roleNames.length > 0) {
return { return {
username: user.username, username: user.username,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: userOrgRole.roleName role: roleNames.join(", ")
}; };
} }
@@ -954,11 +961,15 @@ async function isUserAllowedToAccessResource(
); );
if (userResourceAccess) { if (userResourceAccess) {
const names = await Promise.all(
userOrgRoleIds.map((id) => getRoleName(id))
);
const role = names.filter(Boolean).join(", ") || "";
return { return {
username: user.username, username: user.username,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: userOrgRole.roleName role
}; };
} }
@@ -974,11 +985,11 @@ async function checkRules(
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`; const ruleCacheKey = `rules:${resourceId}`;
let rules: ResourceRule[] | undefined = localCache.get(ruleCacheKey); let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
if (!rules) { if (!rules) {
rules = await getResourceRules(resourceId); rules = await getResourceRules(resourceId);
localCache.set(ruleCacheKey, rules, 5); cache.set(ruleCacheKey, rules, 5);
} }
if (rules.length === 0) { if (rules.length === 0) {
@@ -1208,13 +1219,13 @@ async function isIpInAsn(
async function getAsnFromIp(ip: string): Promise<number | undefined> { async function getAsnFromIp(ip: string): Promise<number | undefined> {
const asnCacheKey = `asn:${ip}`; const asnCacheKey = `asn:${ip}`;
let cachedAsn: number | undefined = localCache.get(asnCacheKey); let cachedAsn: number | undefined = cache.get(asnCacheKey);
if (!cachedAsn) { if (!cachedAsn) {
cachedAsn = await getAsnForIp(ip); // do it locally cachedAsn = await getAsnForIp(ip); // do it locally
// Cache for longer since IP ASN doesn't change frequently // Cache for longer since IP ASN doesn't change frequently
if (cachedAsn) { if (cachedAsn) {
localCache.set(asnCacheKey, cachedAsn, 300); // 5 minutes cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes
} }
} }
@@ -1224,14 +1235,14 @@ async function getAsnFromIp(ip: string): Promise<number | undefined> {
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> { async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
const geoIpCacheKey = `geoip:${ip}`; const geoIpCacheKey = `geoip:${ip}`;
let cachedCountryCode: string | undefined = localCache.get(geoIpCacheKey); let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
if (!cachedCountryCode) { if (!cachedCountryCode) {
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
// Only cache successful lookups to avoid filling cache with undefined values // Only cache successful lookups to avoid filling cache with undefined values
if (cachedCountryCode) { if (cachedCountryCode) {
// Cache for longer since IP geolocation doesn't change frequently // Cache for longer since IP geolocation doesn't change frequently
localCache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
} }
} }

View File

@@ -92,7 +92,7 @@ export async function createClient(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -234,7 +234,7 @@ export async function createClient(
clientId: newClient.clientId clientId: newClient.clientId
}); });
if (req.user && req.userOrgRoleId != adminRole.roleId) { if (req.user && !req.userOrgRoleIds?.includes(adminRole.roleId)) {
// make sure the user can access the client // make sure the user can access the client
trx.insert(userClients).values({ trx.insert(userClients).values({
userId: req.user.userId, userId: req.user.userId,

View File

@@ -297,7 +297,7 @@ export async function listClients(
.where( .where(
or( or(
eq(userClients.userId, req.user!.userId), eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!) inArray(roleClients.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -316,7 +316,7 @@ export async function listUserDevices(
.where( .where(
or( or(
eq(userClients.userId, req.user!.userId), eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!) inArray(roleClients.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -654,6 +654,16 @@ authenticated.post(
user.addUserRole user.addUserRole
); );
authenticated.delete(
"/role/:roleId/remove/:userId",
verifyRoleAccess,
verifyUserAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.removeUserRole),
logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole
);
authenticated.post( authenticated.post(
"/resource/:resourceId/roles", "/resource/:resourceId/roles",
verifyResourceAccess, verifyResourceAccess,

View File

@@ -13,6 +13,7 @@ import {
orgs, orgs,
Role, Role,
roles, roles,
userOrgRoles,
userOrgs, userOrgs,
users users
} from "@server/db"; } from "@server/db";
@@ -570,32 +571,28 @@ export async function validateOidcCallback(
} }
} }
// Update roles for existing auto-provisioned orgs where the role has changed // Ensure IDP-provided role exists for existing auto-provisioned orgs (add only; never delete other roles)
const orgsToUpdate = autoProvisionedOrgs.filter( const userRolesInOrgs = await trx
(currentOrg) => { .select()
const newOrg = userOrgInfo.find( .from(userOrgRoles)
(newOrg) => newOrg.orgId === currentOrg.orgId .where(eq(userOrgRoles.userId, userId!));
); for (const currentOrg of autoProvisionedOrgs) {
return newOrg && newOrg.roleId !== currentOrg.roleId; const newRole = userOrgInfo.find(
} (newOrg) => newOrg.orgId === currentOrg.orgId
); );
if (!newRole) continue;
if (orgsToUpdate.length > 0) { const currentRolesInOrg = userRolesInOrgs.filter(
for (const org of orgsToUpdate) { (r) => r.orgId === currentOrg.orgId
const newRole = userOrgInfo.find( );
(newOrg) => newOrg.orgId === org.orgId const hasIdpRole = currentRolesInOrg.some(
); (r) => r.roleId === newRole.roleId
if (newRole) { );
await trx if (!hasIdpRole) {
.update(userOrgs) await trx.insert(userOrgRoles).values({
.set({ roleId: newRole.roleId }) userId: userId!,
.where( orgId: currentOrg.orgId,
and( roleId: newRole.roleId
eq(userOrgs.userId, userId!), });
eq(userOrgs.orgId, org.orgId)
)
);
}
} }
} }
@@ -619,9 +616,9 @@ export async function validateOidcCallback(
{ {
orgId: org.orgId, orgId: org.orgId,
userId: userId!, userId: userId!,
roleId: org.roleId,
autoProvisioned: true, autoProvisioned: true,
}, },
org.roleId,
trx trx
); );
} }

View File

@@ -532,6 +532,16 @@ authenticated.post(
user.addUserRole user.addUserRole
); );
authenticated.delete(
"/role/:roleId/remove/:userId",
verifyApiKeyRoleAccess,
verifyApiKeyUserAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.removeUserRole),
logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole
);
authenticated.post( authenticated.post(
"/resource/:resourceId/roles", "/resource/:resourceId/roles",
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,

View File

@@ -46,7 +46,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data; const { newtId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );

View File

@@ -24,8 +24,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => {
if (available) { if (available) {
logger.info(`Newt ${newt.newtId} has Docker socket access`); logger.info(`Newt ${newt.newtId} has Docker socket access`);
await cache.set(`${newt.newtId}:socketPath`, socketPath, 0); cache.set(`${newt.newtId}:socketPath`, socketPath, 0);
await cache.set(`${newt.newtId}:isAvailable`, available, 0); cache.set(`${newt.newtId}:isAvailable`, available, 0);
} else { } else {
logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); logger.warn(`Newt ${newt.newtId} does not have Docker socket access`);
} }
@@ -54,7 +54,7 @@ export const handleDockerContainersMessage: MessageHandler = async (
); );
if (containers && containers.length > 0) { if (containers && containers.length > 0) {
await cache.set(`${newt.newtId}:dockerContainers`, containers, 0); cache.set(`${newt.newtId}:dockerContainers`, containers, 0);
} else { } else {
logger.warn(`Newt ${newt.newtId} does not have Docker containers`); logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
} }

View File

@@ -46,7 +46,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data; const { newtId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );

View File

@@ -1,7 +1,4 @@
import { import { generateSessionToken } from "@server/auth/sessions/app";
generateSessionToken,
validateSessionToken
} from "@server/auth/sessions/app";
import { import {
clients, clients,
db, db,
@@ -29,9 +26,8 @@ import { APP_VERSION } from "@server/lib/consts";
export const olmGetTokenBodySchema = z.object({ export const olmGetTokenBodySchema = z.object({
olmId: z.string(), olmId: z.string(),
secret: z.string().optional(), secret: z.string(),
userToken: z.string().optional(), token: z.string().optional(),
token: z.string().optional(), // this is the olm token
orgId: z.string().optional() orgId: z.string().optional()
}); });
@@ -53,7 +49,7 @@ export async function getOlmToken(
); );
} }
const { olmId, secret, token, orgId, userToken } = parsedBody.data; const { olmId, secret, token, orgId } = parsedBody.data;
try { try {
if (token) { if (token) {
@@ -88,45 +84,19 @@ export async function getOlmToken(
); );
} }
if (userToken) { const validSecret = await verifyPassword(
const { session: userSession, user } = secret,
await validateSessionToken(userToken); existingOlm.secretHash
if (!userSession || !user) { );
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid user token")
);
}
if (user.userId !== existingOlm.userId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User token does not match olm"
)
);
}
} else if (secret) {
// this is for backward compatibility, we want to move towards userToken but some old clients may still be using secret so we will support both for now
const validSecret = await verifyPassword(
secret,
existingOlm.secretHash
);
if (!validSecret) { if (!validSecret) {
if (config.getRawConfig().app.log_failed_attempts) { if (config.getRawConfig().app.log_failed_attempts) {
logger.info( logger.info(
`Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.` `Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
); );
} }
} else {
return next( return next(
createHttpError( createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
HttpCode.BAD_REQUEST,
"Either secret or userToken is required"
)
); );
} }

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, idp, idpOidcConfig } from "@server/db"; import { db, idp, idpOidcConfig } from "@server/db";
import { roles, userOrgs, users } from "@server/db"; import { roles, userOrgRoles, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy";
async function queryUser(orgId: string, userId: string) { async function queryUser(orgId: string, userId: string) {
const [user] = await db const [userRow] = await db
.select({ .select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
userId: users.userId, userId: users.userId,
@@ -22,10 +22,7 @@ async function queryUser(orgId: string, userId: string) {
username: users.username, username: users.username,
name: users.name, name: users.name,
type: users.type, type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled, twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned, autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId, idpId: users.idpId,
@@ -35,13 +32,40 @@ async function queryUser(orgId: string, userId: string) {
idpAutoProvision: idp.autoProvision idpAutoProvision: idp.autoProvision
}) })
.from(userOrgs) .from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1); .limit(1);
return user;
if (!userRow) return undefined;
const roleRows = await db
.select({
roleId: userOrgRoles.roleId,
roleName: roles.name,
isAdmin: roles.isAdmin
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
);
const isAdmin = roleRows.some((r) => r.isAdmin);
return {
...userRow,
isAdmin,
roleIds: roleRows.map((r) => r.roleId),
roles: roleRows.map((r) => ({
roleId: r.roleId,
name: r.roleName ?? ""
}))
};
} }
export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult;

View File

@@ -9,6 +9,7 @@ import {
orgs, orgs,
roleActions, roleActions,
roles, roles,
userOrgRoles,
userOrgs, userOrgs,
users, users,
actions actions
@@ -312,9 +313,13 @@ export async function createOrg(
await trx.insert(userOrgs).values({ await trx.insert(userOrgs).values({
userId: req.user!.userId, userId: req.user!.userId,
orgId: newOrg[0].orgId, orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true isOwner: true
}); });
await trx.insert(userOrgRoles).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
roleId
});
ownerUserId = req.user!.userId; ownerUserId = req.user!.userId;
} else { } else {
// if org created by root api key, set the server admin as the owner // if org created by root api key, set the server admin as the owner
@@ -332,9 +337,13 @@ export async function createOrg(
await trx.insert(userOrgs).values({ await trx.insert(userOrgs).values({
userId: serverAdmin.userId, userId: serverAdmin.userId,
orgId: newOrg[0].orgId, orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true isOwner: true
}); });
await trx.insert(userOrgRoles).values({
userId: serverAdmin.userId,
orgId: newOrg[0].orgId,
roleId
});
ownerUserId = serverAdmin.userId; ownerUserId = serverAdmin.userId;
} }

View File

@@ -117,20 +117,26 @@ export async function getOrgOverview(
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.orgId, orgId)); .where(eq(userOrgs.orgId, orgId));
const [role] = await db const roleIds = req.userOrgRoleIds ?? [];
.select() const roleRows =
.from(roles) roleIds.length > 0
.where(eq(roles.roleId, req.userOrg.roleId)); ? await db
.select({ name: roles.name, isAdmin: roles.isAdmin })
.from(roles)
.where(inArray(roles.roleId, roleIds))
: [];
const userRoleName = roleRows.map((r) => r.name ?? "").join(", ") ?? "";
const isAdmin = roleRows.some((r) => r.isAdmin === true);
return response<GetOrgOverviewResponse>(res, { return response<GetOrgOverviewResponse>(res, {
data: { data: {
orgName: org[0].name, orgName: org[0].name,
orgId: org[0].orgId, orgId: org[0].orgId,
userRoleName: role.name, userRoleName,
numSites, numSites,
numUsers, numUsers,
numResources, numResources,
isAdmin: role.isAdmin || false, isAdmin,
isOwner: req.userOrg?.isOwner || false isOwner: req.userOrg?.isOwner || false
}, },
success: true, success: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, roles } from "@server/db"; import { db, roles } from "@server/db";
import { Org, orgs, userOrgs } from "@server/db"; import { Org, orgs, userOrgRoles, userOrgs } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -82,10 +82,7 @@ export async function listUserOrgs(
const { userId } = parsedParams.data; const { userId } = parsedParams.data;
const userOrganizations = await db const userOrganizations = await db
.select({ .select({ orgId: userOrgs.orgId })
orgId: userOrgs.orgId,
roleId: userOrgs.roleId
})
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.userId, userId)); .where(eq(userOrgs.userId, userId));
@@ -116,10 +113,27 @@ export async function listUserOrgs(
userOrgs, userOrgs,
and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId))
) )
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
const roleRows = await db
.select({
orgId: userOrgRoles.orgId,
isAdmin: roles.isAdmin
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
inArray(userOrgRoles.orgId, userOrgIds)
)
);
const orgHasAdmin = new Set(
roleRows.filter((r) => r.isAdmin).map((r) => r.orgId)
);
const totalCountResult = await db const totalCountResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` }) .select({ count: sql<number>`cast(count(*) as integer)` })
.from(orgs) .from(orgs)
@@ -133,8 +147,8 @@ export async function listUserOrgs(
if (val.userOrgs && val.userOrgs.isOwner) { if (val.userOrgs && val.userOrgs.isOwner) {
res.isOwner = val.userOrgs.isOwner; res.isOwner = val.userOrgs.isOwner;
} }
if (val.roles && val.roles.isAdmin) { if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) {
res.isAdmin = val.roles.isAdmin; res.isAdmin = true;
} }
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) { if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
res.isPrimaryOrg = val.orgs.isBillingOrg; res.isPrimaryOrg = val.orgs.isBillingOrg;

View File

@@ -194,9 +194,9 @@ export async function updateOrg(
} }
// invalidate the cache for all of the orgs retention days // invalidate the cache for all of the orgs retention days
await cache.del(`org_${orgId}_retentionDays`); cache.del(`org_${orgId}_retentionDays`);
await cache.del(`org_${orgId}_actionDays`); cache.del(`org_${orgId}_actionDays`);
await cache.del(`org_${orgId}_accessDays`); cache.del(`org_${orgId}_accessDays`);
return response(res, { return response(res, {
data: updatedOrg[0], data: updatedOrg[0],

View File

@@ -112,7 +112,7 @@ export async function createResource(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -278,7 +278,7 @@ async function createHttpResource(
resourceId: newResource[0].resourceId resourceId: newResource[0].resourceId
}); });
if (req.user && req.userOrgRoleId != adminRole[0].roleId) { if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the resource // make sure the user can access the resource
await trx.insert(userResources).values({ await trx.insert(userResources).values({
userId: req.user?.userId!, userId: req.user?.userId!,
@@ -371,7 +371,7 @@ async function createRawResource(
resourceId: newResource[0].resourceId resourceId: newResource[0].resourceId
}); });
if (req.user && req.userOrgRoleId != adminRole[0].roleId) { if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the resource // make sure the user can access the resource
await trx.insert(userResources).values({ await trx.insert(userResources).values({
userId: req.user?.userId!, userId: req.user?.userId!,

View File

@@ -5,6 +5,7 @@ import {
resources, resources,
userResources, userResources,
roleResources, roleResources,
userOrgRoles,
userOrgs, userOrgs,
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
@@ -32,22 +33,29 @@ export async function getUserResources(
); );
} }
// First get the user's role in the organization // Check user is in organization and get their role IDs
const userOrgResult = await db const [userOrg] = await db
.select({ .select()
roleId: userOrgs.roleId
})
.from(userOrgs) .from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1); .limit(1);
if (userOrgResult.length === 0) { if (!userOrg) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User not in organization") createHttpError(HttpCode.FORBIDDEN, "User not in organization")
); );
} }
const userRoleId = userOrgResult[0].roleId; const userRoleIds = await db
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
)
.then((rows) => rows.map((r) => r.roleId));
// Get resources accessible through direct assignment or role assignment // Get resources accessible through direct assignment or role assignment
const directResourcesQuery = db const directResourcesQuery = db
@@ -55,20 +63,28 @@ export async function getUserResources(
.from(userResources) .from(userResources)
.where(eq(userResources.userId, userId)); .where(eq(userResources.userId, userId));
const roleResourcesQuery = db const roleResourcesQuery =
.select({ resourceId: roleResources.resourceId }) userRoleIds.length > 0
.from(roleResources) ? db
.where(eq(roleResources.roleId, userRoleId)); .select({ resourceId: roleResources.resourceId })
.from(roleResources)
.where(inArray(roleResources.roleId, userRoleIds))
: Promise.resolve([]);
const directSiteResourcesQuery = db const directSiteResourcesQuery = db
.select({ siteResourceId: userSiteResources.siteResourceId }) .select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources) .from(userSiteResources)
.where(eq(userSiteResources.userId, userId)); .where(eq(userSiteResources.userId, userId));
const roleSiteResourcesQuery = db const roleSiteResourcesQuery =
.select({ siteResourceId: roleSiteResources.siteResourceId }) userRoleIds.length > 0
.from(roleSiteResources) ? db
.where(eq(roleSiteResources.roleId, userRoleId)); .select({
siteResourceId: roleSiteResources.siteResourceId
})
.from(roleSiteResources)
.where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([]);
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
directResourcesQuery, directResourcesQuery,

View File

@@ -276,7 +276,7 @@ export async function listResources(
.where( .where(
or( or(
eq(userResources.userId, req.user!.userId), eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!) inArray(roleResources.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { roles, userOrgs } from "@server/db"; import { roles, userOrgRoles } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -114,11 +114,11 @@ export async function deleteRole(
} }
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// move all users from the userOrgs table with roleId to newRoleId // move all users from userOrgRoles with roleId to newRoleId
await trx await trx
.update(userOrgs) .update(userOrgRoles)
.set({ roleId: newRoleId }) .set({ roleId: newRoleId })
.where(eq(userOrgs.roleId, roleId)); .where(eq(userOrgRoles.roleId, roleId));
// delete the old role // delete the old role
await trx.delete(roles).where(eq(roles.roleId, roleId)); await trx.delete(roles).where(eq(roles.roleId, roleId));

View File

@@ -111,7 +111,7 @@ export async function createSite(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -399,7 +399,7 @@ export async function createSite(
siteId: newSite.siteId siteId: newSite.siteId
}); });
if (req.user && req.userOrgRoleId != adminRole[0].roleId) { if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the site // make sure the user can access the site
trx.insert(userSites).values({ trx.insert(userSites).values({
userId: req.user?.userId!, userId: req.user?.userId!,

View File

@@ -23,7 +23,7 @@ import { fromError } from "zod-validation-error";
async function getLatestNewtVersion(): Promise<string | null> { async function getLatestNewtVersion(): Promise<string | null> {
try { try {
const cachedVersion = await cache.get<string>("latestNewtVersion"); const cachedVersion = cache.get<string>("latestNewtVersion");
if (cachedVersion) { if (cachedVersion) {
return cachedVersion; return cachedVersion;
} }
@@ -55,7 +55,7 @@ async function getLatestNewtVersion(): Promise<string | null> {
tags = tags.filter((version) => !version.name.includes("rc")); tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name; const latestVersion = tags[0].name;
await cache.set("latestNewtVersion", latestVersion); cache.set("latestNewtVersion", latestVersion);
return latestVersion; return latestVersion;
} catch (error: any) { } catch (error: any) {
@@ -235,7 +235,7 @@ export async function listSites(
.where( .where(
or( or(
eq(userSites.userId, req.user!.userId), eq(userSites.userId, req.user!.userId),
eq(roleSites.roleId, req.userOrgRoleId!) inArray(roleSites.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -150,7 +150,7 @@ async function triggerFetch(siteId: number) {
// clear the cache for this Newt ID so that the site has to keep asking for the containers // clear the cache for this Newt ID so that the site has to keep asking for the containers
// this is to ensure that the site always gets the latest data // this is to ensure that the site always gets the latest data
await cache.del(`${newt.newtId}:dockerContainers`); cache.del(`${newt.newtId}:dockerContainers`);
return { siteId, newtId: newt.newtId }; return { siteId, newtId: newt.newtId };
} }
@@ -158,7 +158,7 @@ async function triggerFetch(siteId: number) {
async function queryContainers(siteId: number) { async function queryContainers(siteId: number) {
const { newt } = await getSiteAndNewt(siteId); const { newt } = await getSiteAndNewt(siteId);
const result = await cache.get<Container[]>(`${newt.newtId}:dockerContainers`); const result = cache.get(`${newt.newtId}:dockerContainers`) as Container[];
if (!result) { if (!result) {
throw createHttpError( throw createHttpError(
HttpCode.TOO_EARLY, HttpCode.TOO_EARLY,
@@ -173,7 +173,7 @@ async function isDockerAvailable(siteId: number): Promise<boolean> {
const { newt } = await getSiteAndNewt(siteId); const { newt } = await getSiteAndNewt(siteId);
const key = `${newt.newtId}:isAvailable`; const key = `${newt.newtId}:isAvailable`;
const isAvailable = await cache.get(key); const isAvailable = cache.get(key);
return !!isAvailable; return !!isAvailable;
} }
@@ -186,11 +186,9 @@ async function getDockerStatus(
const keys = ["isAvailable", "socketPath"]; const keys = ["isAvailable", "socketPath"];
const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`); const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`);
const values = await cache.mget<boolean | string>(mappedKeys);
const result = { const result = {
isAvailable: values[0] as boolean, isAvailable: cache.get(mappedKeys[0]) as boolean,
socketPath: values[1] as string | undefined socketPath: cache.get(mappedKeys[1]) as string | undefined
}; };
return result; return result;

View File

@@ -165,9 +165,9 @@ export async function acceptInvite(
org, org,
{ {
userId: existingUser[0].userId, userId: existingUser[0].userId,
orgId: existingInvite.orgId, orgId: existingInvite.orgId
roleId: existingInvite.roleId
}, },
existingInvite.roleId,
trx trx
); );

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { clients, db, UserOrg } from "@server/db"; import { clients, db } from "@server/db";
import { userOrgs, roles } from "@server/db"; import { userOrgRoles, userOrgs, roles } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -111,20 +111,23 @@ export async function addUserRole(
); );
} }
let newUserRole: UserOrg | null = null; let newUserRole: { userId: string; orgId: string; roleId: number } | null =
null;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
[newUserRole] = await trx const inserted = await trx
.update(userOrgs) .insert(userOrgRoles)
.set({ roleId }) .values({
.where( userId,
and( orgId: role.orgId,
eq(userOrgs.userId, userId), roleId
eq(userOrgs.orgId, role.orgId) })
) .onConflictDoNothing()
)
.returning(); .returning();
// get the client associated with this user in this org if (inserted.length > 0) {
newUserRole = inserted[0];
}
const orgClients = await trx const orgClients = await trx
.select() .select()
.from(clients) .from(clients)
@@ -133,17 +136,15 @@ export async function addUserRole(
eq(clients.userId, userId), eq(clients.userId, userId),
eq(clients.orgId, role.orgId) eq(clients.orgId, role.orgId)
) )
) );
.limit(1);
for (const orgClient of orgClients) { for (const orgClient of orgClients) {
// we just changed the user's role, so we need to rebuild client associations and what they have access to
await rebuildClientAssociationsFromClient(orgClient, trx); await rebuildClientAssociationsFromClient(orgClient, trx);
} }
}); });
return response(res, { return response(res, {
data: newUserRole, data: newUserRole ?? { userId, orgId: role.orgId, roleId },
success: true, success: true,
error: false, error: false,
message: "Role added to user successfully", message: "Role added to user successfully",

View File

@@ -221,12 +221,16 @@ export async function createOrgUser(
); );
} }
await assignUserToOrg(org, { await assignUserToOrg(
orgId, org,
userId: existingUser.userId, {
roleId: role.roleId, orgId,
autoProvisioned: false userId: existingUser.userId,
}, trx); autoProvisioned: false,
},
role.roleId,
trx
);
} else { } else {
userId = generateId(15); userId = generateId(15);
@@ -244,12 +248,16 @@ export async function createOrgUser(
}) })
.returning(); .returning();
await assignUserToOrg(org, { await assignUserToOrg(
orgId, org,
userId: newUser.userId, {
roleId: role.roleId, orgId,
autoProvisioned: false userId: newUser.userId,
}, trx); autoProvisioned: false,
},
role.roleId,
trx
);
} }
await calculateUserClientsForOrgs(userId, trx); await calculateUserClientsForOrgs(userId, trx);

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, idp, idpOidcConfig } from "@server/db"; import { db, idp, idpOidcConfig } from "@server/db";
import { roles, userOrgs, users } from "@server/db"; import { roles, userOrgRoles, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -12,7 +12,7 @@ import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
async function queryUser(orgId: string, userId: string) { async function queryUser(orgId: string, userId: string) {
const [user] = await db const [userRow] = await db
.select({ .select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
userId: users.userId, userId: users.userId,
@@ -20,10 +20,7 @@ async function queryUser(orgId: string, userId: string) {
username: users.username, username: users.username,
name: users.name, name: users.name,
type: users.type, type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled, twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned, autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId, idpId: users.idpId,
@@ -33,13 +30,40 @@ async function queryUser(orgId: string, userId: string) {
idpAutoProvision: idp.autoProvision idpAutoProvision: idp.autoProvision
}) })
.from(userOrgs) .from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1); .limit(1);
return user;
if (!userRow) return undefined;
const roleRows = await db
.select({
roleId: userOrgRoles.roleId,
roleName: roles.name,
isAdmin: roles.isAdmin
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
);
const isAdmin = roleRows.some((r) => r.isAdmin);
return {
...userRow,
isAdmin,
roleIds: roleRows.map((r) => r.roleId),
roles: roleRows.map((r) => ({
roleId: r.roleId,
name: r.roleName ?? ""
}))
};
} }
export type GetOrgUserResponse = NonNullable< export type GetOrgUserResponse = NonNullable<

View File

@@ -2,6 +2,7 @@ export * from "./getUser";
export * from "./removeUserOrg"; export * from "./removeUserOrg";
export * from "./listUsers"; export * from "./listUsers";
export * from "./addUserRole"; export * from "./addUserRole";
export * from "./removeUserRole";
export * from "./inviteUser"; export * from "./inviteUser";
export * from "./acceptInvite"; export * from "./acceptInvite";
export * from "./getOrgUser"; export * from "./getOrgUser";

View File

@@ -191,7 +191,7 @@ export async function inviteUser(
} }
if (existingInvite.length) { if (existingInvite.length) {
const attempts = (await cache.get<number>(email)) || 0; const attempts = cache.get<number>(email) || 0;
if (attempts >= 3) { if (attempts >= 3) {
return next( return next(
createHttpError( createHttpError(
@@ -201,7 +201,7 @@ export async function inviteUser(
); );
} }
await cache.set(email, attempts + 1); cache.set(email, attempts + 1);
const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId
const token = generateRandomString( const token = generateRandomString(

View File

@@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, idpOidcConfig } from "@server/db"; import { db, idpOidcConfig } from "@server/db";
import { idp, roles, userOrgs, users } from "@server/db"; import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { and, sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -31,7 +31,7 @@ const listUsersSchema = z.strictObject({
}); });
async function queryUsers(orgId: string, limit: number, offset: number) { async function queryUsers(orgId: string, limit: number, offset: number) {
return await db const rows = await db
.select({ .select({
id: users.userId, id: users.userId,
email: users.email, email: users.email,
@@ -41,8 +41,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
username: users.username, username: users.username,
name: users.name, name: users.name,
type: users.type, type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId, idpId: users.idpId,
@@ -52,12 +50,39 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
}) })
.from(users) .from(users)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId)) .where(eq(userOrgs.orgId, orgId))
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
const roleRows = await db
.select({
userId: userOrgRoles.userId,
roleId: userOrgRoles.roleId,
roleName: roles.name
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgRoles.orgId, orgId));
const rolesByUser = new Map<
string,
{ roleId: number; roleName: string }[]
>();
for (const r of roleRows) {
const list = rolesByUser.get(r.userId) ?? [];
list.push({ roleId: r.roleId, roleName: r.roleName ?? "" });
rolesByUser.set(r.userId, list);
}
return rows.map((row) => {
const userRoles = rolesByUser.get(row.id) ?? [];
return {
...row,
roles: userRoles
};
});
} }
export type ListUsersResponse = { export type ListUsersResponse = {

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, Olm, olms, orgs, userOrgs } from "@server/db"; import { db, Olm, olms, orgs, userOrgRoles, userOrgs } from "@server/db";
import { idp, users } from "@server/db"; import { idp, users } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -84,16 +84,31 @@ export async function myDevice(
.from(olms) .from(olms)
.where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId)));
const userOrganizations = await db const userOrgRows = await db
.select({ .select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
orgName: orgs.name, orgName: orgs.name
roleId: userOrgs.roleId
}) })
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.userId, userId)) .where(eq(userOrgs.userId, userId))
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId));
const roleRows = await db
.select({
orgId: userOrgRoles.orgId,
roleId: userOrgRoles.roleId
})
.from(userOrgRoles)
.where(eq(userOrgRoles.userId, userId));
const roleByOrg = new Map(
roleRows.map((r) => [r.orgId, r.roleId])
);
const userOrganizations = userOrgRows.map((row) => ({
...row,
roleId: roleByOrg.get(row.orgId) ?? 0
}));
return response<MyDeviceResponse>(res, { return response<MyDeviceResponse>(res, {
data: { data: {
user, user,

View File

@@ -0,0 +1,157 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import stoi from "@server/lib/stoi";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
const removeUserRoleParamsSchema = z.strictObject({
userId: z.string(),
roleId: z.string().transform(stoi).pipe(z.number())
});
registry.registerPath({
method: "delete",
path: "/role/{roleId}/remove/{userId}",
description: "Remove a role from a user. User must have at least one role left in the org.",
tags: [OpenAPITags.Role, OpenAPITags.User],
request: {
params: removeUserRoleParamsSchema
},
responses: {}
});
export async function removeUserRole(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = removeUserRoleParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { userId, roleId } = parsedParams.data;
if (req.user && !req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have access to this organization"
)
);
}
const [role] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
if (!role) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
);
}
const [existingUser] = await db
.select()
.from(userOrgs)
.where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))
)
.limit(1);
if (!existingUser) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found or does not belong to the specified organization"
)
);
}
if (existingUser.isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the roles of the owner of the organization"
)
);
}
const remainingRoles = await db
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, role.orgId)
)
);
if (remainingRoles.length <= 1) {
const hasThisRole = remainingRoles.some((r) => r.roleId === roleId);
if (hasThisRole) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User must have at least one role in the organization. Remove the last role is not allowed."
)
);
}
}
await db.transaction(async (trx) => {
await trx
.delete(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, role.orgId),
eq(userOrgRoles.roleId, roleId)
)
);
const orgClients = await trx
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, role.orgId)
)
);
for (const orgClient of orgClients) {
await rebuildClientAssociationsFromClient(orgClient, trx);
}
});
return response(res, {
data: { userId, orgId: role.orgId, roleId },
success: true,
error: false,
message: "Role removed from user successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -5,5 +5,5 @@ import { Session } from "@server/db";
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
user: User; user: User;
session: Session; session: Session;
userOrgRoleId?: number; userOrgRoleIds?: number[];
} }

View File

@@ -8,7 +8,6 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,7 +18,6 @@ import {
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -44,6 +42,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge"; import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { Badge } from "@app/components/ui/badge";
type UserRole = { roleId: number; name: string };
export default function AccessControlsPage() { export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext(); const { orgUser: user } = userOrgUserContext();
@@ -54,12 +55,12 @@ export default function AccessControlsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const t = useTranslations(); const t = useTranslations();
const formSchema = z.object({ const formSchema = z.object({
username: z.string(), username: z.string(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
autoProvisioned: z.boolean() autoProvisioned: z.boolean()
}); });
@@ -67,11 +68,17 @@ export default function AccessControlsPage() {
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
username: user.username!, username: user.username!,
roleId: user.roleId?.toString(),
autoProvisioned: user.autoProvisioned || false autoProvisioned: user.autoProvisioned || false
} }
}); });
const currentRoleIds = user.roleIds ?? [];
const currentRoles: UserRole[] = user.roles ?? [];
useEffect(() => {
setUserRoles(currentRoles);
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => { useEffect(() => {
async function fetchRoles() { async function fetchRoles() {
const res = await api const res = await api
@@ -94,32 +101,20 @@ export default function AccessControlsPage() {
} }
fetchRoles(); fetchRoles();
form.setValue("roleId", user.roleId.toString());
form.setValue("autoProvisioned", user.autoProvisioned || false); form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []); }, []);
async function onSubmit(values: z.infer<typeof formSchema>) { async function handleAddRole(roleId: number) {
setLoading(true); setLoading(true);
try { try {
// Execute both API calls simultaneously await api.post(`/role/${roleId}/add/${user.userId}`);
const [roleRes, userRes] = await Promise.all([ toast({
api.post<AxiosResponse<InviteUserResponse>>( variant: "default",
`/role/${values.roleId}/add/${user.userId}` title: t("userSaved"),
), description: t("userSavedDescription")
api.post(`/org/${orgId}/user/${user.userId}`, { });
autoProvisioned: values.autoProvisioned const role = roles.find((r) => r.roleId === roleId);
}) if (role) setUserRoles((prev) => [...prev, role]);
]);
if (roleRes.status === 200 && userRes.status === 200) {
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
}
} catch (e) { } catch (e) {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -130,10 +125,61 @@ export default function AccessControlsPage() {
) )
}); });
} }
setLoading(false); setLoading(false);
} }
async function handleRemoveRole(roleId: number) {
setLoading(true);
try {
await api.delete(`/role/${roleId}/remove/${user.userId}`);
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
setUserRoles((prev) => prev.filter((r) => r.roleId !== roleId));
} catch (e) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: formatAxiosError(
e,
t("accessRoleErrorAddDescription")
)
});
}
setLoading(false);
}
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
try {
await api.post(`/org/${orgId}/user/${user.userId}`, {
autoProvisioned: values.autoProvisioned
});
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
} catch (e) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: formatAxiosError(
e,
t("accessRoleErrorAddDescription")
)
});
}
setLoading(false);
}
const availableRolesToAdd = roles.filter(
(r) => !userRoles.some((ur) => ur.roleId === r.roleId)
);
const canRemoveRole = userRoles.length > 1;
return ( return (
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
@@ -154,7 +200,6 @@ export default function AccessControlsPage() {
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
{/* IDP Type Display */}
{user.type !== UserType.Internal && {user.type !== UserType.Internal &&
user.idpType && ( user.idpType && (
<div className="flex items-center space-x-2 mb-4"> <div className="flex items-center space-x-2 mb-4">
@@ -171,49 +216,72 @@ export default function AccessControlsPage() {
</div> </div>
)} )}
<FormField <FormItem>
control={form.control} <FormLabel>{t("role")}</FormLabel>
name="roleId" <div className="flex flex-wrap gap-2 items-center">
render={({ field }) => ( {userRoles.map((r) => (
<FormItem> <Badge
<FormLabel>{t("role")}</FormLabel> key={r.roleId}
variant="secondary"
className="flex items-center gap-1"
>
{r.name}
{canRemoveRole && (
<button
type="button"
onClick={() =>
handleRemoveRole(
r.roleId
)
}
disabled={loading}
className="ml-1 rounded hover:bg-muted"
aria-label={`Remove ${r.name}`}
>
×
</button>
)}
</Badge>
))}
{availableRolesToAdd.length > 0 && (
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
field.onChange(value); handleAddRole(
// If auto provision is enabled, set it to false when role changes parseInt(value, 10)
if (user.idpAutoProvision) { );
form.setValue(
"autoProvisioned",
false
);
}
}} }}
value={field.value} disabled={loading}
> >
<FormControl> <SelectTrigger className="w-[180px]">
<SelectTrigger> <SelectValue
<SelectValue placeholder={t(
placeholder={t( "accessRoleSelect"
"accessRoleSelect" )}
)} />
/> </SelectTrigger>
</SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
{roles.map((role) => ( {availableRolesToAdd.map(
<SelectItem (role) => (
key={role.roleId} <SelectItem
value={role.roleId.toString()} key={
> role.roleId
{role.name} }
</SelectItem> value={role.roleId.toString()}
))} >
{role.name}
</SelectItem>
)
)}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> )}
</FormItem> </div>
{userRoles.length === 0 && (
<p className="text-sm text-muted-foreground">
{t("accessRoleSelectPlease")}
</p>
)} )}
/> </FormItem>
{user.idpAutoProvision && ( {user.idpAutoProvision && (
<FormField <FormField
@@ -231,7 +299,9 @@ export default function AccessControlsPage() {
</FormControl> </FormControl>
<div className="space-y-1 leading-none"> <div className="space-y-1 leading-none">
<FormLabel> <FormLabel>
{t("autoProvisioned")} {t(
"autoProvisioned"
)}
</FormLabel> </FormLabel>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t(

View File

@@ -88,7 +88,9 @@ export default async function UsersPage(props: UsersPageProps) {
status: t("userConfirmed"), status: t("userConfirmed"),
role: user.isOwner role: user.isOwner
? t("accessRoleOwner") ? t("accessRoleOwner")
: user.roleName || t("accessRoleMember"), : user.roles?.length
? user.roles.map((r) => r.roleName).join(", ")
: t("accessRoleMember"),
isOwner: user.isOwner || false isOwner: user.isOwner || false
}; };
}); });

View File

@@ -5,13 +5,11 @@ import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector"; import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ExternalLink, Menu, Server } from "lucide-react"; import { ArrowRight, Menu, Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import ProfileIcon from "@app/components/ProfileIcon"; import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher"; import ThemeSwitcher from "@app/components/ThemeSwitcher";
@@ -44,7 +42,6 @@ export function LayoutMobileMenu({
const pathname = usePathname(); const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin"); const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext(); const { user } = useUserContext();
const { env } = useEnvContext();
const t = useTranslations(); const t = useTranslations();
return ( return (
@@ -83,7 +80,7 @@ export function LayoutMobileMenu({
<div className="px-3 pt-3"> <div className="px-3 pt-3">
{!isAdminPage && {!isAdminPage &&
user.serverAdmin && ( user.serverAdmin && (
<div className="py-2"> <div className="mb-1">
<Link <Link
href="/admin" href="/admin"
className={cn( className={cn(
@@ -98,11 +95,12 @@ export function LayoutMobileMenu({
<span className="flex-shrink-0 mr-2"> <span className="flex-shrink-0 mr-2">
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
</span> </span>
<span> <span className="flex-1">
{t( {t(
"serverAdmin" "serverAdmin"
)} )}
</span> </span>
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
</Link> </Link>
</div> </div>
)} )}
@@ -115,22 +113,6 @@ export function LayoutMobileMenu({
</div> </div>
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" /> <div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div> </div>
<div className="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
<SupporterStatus />
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div>
)}
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</div> </div>

View File

@@ -146,6 +146,46 @@ export function LayoutSidebar({
/> />
<div className="flex-1 overflow-y-auto relative"> <div className="flex-1 overflow-y-auto relative">
<div className="px-2 pt-3"> <div className="px-2 pt-3">
{!isAdminPage && user.serverAdmin && (
<div
className={cn(
"shrink-0",
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
className={cn(
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<>
<span className="flex-1">
{t("serverAdmin")}
</span>
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
</>
)}
</Link>
</div>
)}
<SidebarNav <SidebarNav
sections={navItems} sections={navItems}
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
@@ -156,40 +196,6 @@ export function LayoutSidebar({
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" /> <div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div> </div>
{!isAdminPage && user.serverAdmin && (
<div className="shrink-0 px-2 pb-2">
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed ? t("serverAdmin") : undefined
}
>
<span
className={cn(
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<>
<span className="flex-1">
{t("serverAdmin")}
</span>
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
</>
)}
</Link>
</div>
)}
{isSidebarCollapsed && ( {isSidebarCollapsed && (
<div className="shrink-0 flex justify-center py-2"> <div className="shrink-0 flex justify-center py-2">
<TooltipProvider> <TooltipProvider>
@@ -218,7 +224,7 @@ export function LayoutSidebar({
<div className="w-full border-t border-border mb-3" /> <div className="w-full border-t border-border mb-3" />
<div className="p-4 pt-0 mt-0 flex flex-col shrink-0"> <div className="p-4 pt-1 flex flex-col shrink-0">
{canShowProductUpdates && ( {canShowProductUpdates && (
<div className="mb-3 empty:mb-0"> <div className="mb-3 empty:mb-0">
<ProductUpdates isCollapsed={isSidebarCollapsed} /> <ProductUpdates isCollapsed={isSidebarCollapsed} />

View File

@@ -2,22 +2,31 @@ import { headers } from "next/headers";
export async function authCookieHeader() { export async function authCookieHeader() {
const otherHeaders = await headers(); const otherHeaders = await headers();
const otherHeadersObject = Object.fromEntries( const otherHeadersObject = Object.fromEntries(otherHeaders.entries());
Array.from(otherHeaders.entries()).map(([k, v]) => [k.toLowerCase(), v])
);
console.info(`Setting cookie... x-forwarded-for: ${otherHeadersObject["x-forwarded-for"]}`)
return { return {
headers: { headers: {
cookie: otherHeadersObject["cookie"], cookie:
host: otherHeadersObject["host"], otherHeadersObject["cookie"] || otherHeadersObject["Cookie"],
"user-agent": otherHeadersObject["user-agent"], host: otherHeadersObject["host"] || otherHeadersObject["Host"],
"x-forwarded-for": otherHeadersObject["x-forwarded-for"], "user-agent":
"x-forwarded-host": otherHeadersObject["x-forwarded-host"], otherHeadersObject["user-agent"] ||
"x-forwarded-port": otherHeadersObject["x-forwarded-port"], otherHeadersObject["User-Agent"],
"x-forwarded-proto": otherHeadersObject["x-forwarded-proto"], "x-forwarded-for":
"x-real-ip": otherHeadersObject["x-real-ip"] otherHeadersObject["x-forwarded-for"] ||
otherHeadersObject["X-Forwarded-For"],
"x-forwarded-host":
otherHeadersObject["fx-forwarded-host"] ||
otherHeadersObject["Fx-Forwarded-Host"],
"x-forwarded-port":
otherHeadersObject["x-forwarded-port"] ||
otherHeadersObject["X-Forwarded-Port"],
"x-forwarded-proto":
otherHeadersObject["x-forwarded-proto"] ||
otherHeadersObject["X-Forwarded-Proto"],
"x-real-ip":
otherHeadersObject["x-real-ip"] ||
otherHeadersObject["X-Real-IP"]
} }
}; };
} }