mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-25 12:06:37 +00:00
Compare commits
2 Commits
main
...
multi-role
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fecbe704b | ||
|
|
20e547a0f6 |
@@ -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";
|
||||||
|
|
||||||
@@ -53,6 +53,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",
|
||||||
@@ -154,29 +155,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
|
||||||
@@ -187,7 +178,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);
|
||||||
@@ -196,14 +187,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!)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
||||||
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
|
||||||
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
await stopPingAccumulator();
|
|
||||||
await flushBandwidthToDb();
|
await flushBandwidthToDb();
|
||||||
await flushSiteBandwidthToDb();
|
await flushSiteBandwidthToDb();
|
||||||
await wsCleanup();
|
await wsCleanup();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool } from "pg";
|
||||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
import { withReplicas } from "drizzle-orm/pg-core";
|
||||||
import { createPool } from "./poolConfig";
|
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const config = readConfigFile();
|
const config = readConfigFile();
|
||||||
@@ -39,17 +39,12 @@ function createDb() {
|
|||||||
|
|
||||||
// Create connection pools instead of individual connections
|
// Create connection pools instead of individual connections
|
||||||
const poolConfig = config.postgres.pool;
|
const poolConfig = config.postgres.pool;
|
||||||
const maxConnections = poolConfig?.max_connections || 20;
|
const primaryPool = new Pool({
|
||||||
const idleTimeoutMs = poolConfig?.idle_timeout_ms || 30000;
|
|
||||||
const connectionTimeoutMs = poolConfig?.connection_timeout_ms || 5000;
|
|
||||||
|
|
||||||
const primaryPool = createPool(
|
|
||||||
connectionString,
|
connectionString,
|
||||||
maxConnections,
|
max: poolConfig?.max_connections || 20,
|
||||||
idleTimeoutMs,
|
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||||
connectionTimeoutMs,
|
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000
|
||||||
"primary"
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const replicas = [];
|
const replicas = [];
|
||||||
|
|
||||||
@@ -60,16 +55,14 @@ function createDb() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const maxReplicaConnections =
|
|
||||||
poolConfig?.max_replica_connections || 20;
|
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replicaPool = createPool(
|
const replicaPool = new Pool({
|
||||||
conn.connection_string,
|
connectionString: conn.connection_string,
|
||||||
maxReplicaConnections,
|
max: poolConfig?.max_replica_connections || 20,
|
||||||
idleTimeoutMs,
|
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||||
connectionTimeoutMs,
|
connectionTimeoutMillis:
|
||||||
"replica"
|
poolConfig?.connection_timeout_ms || 5000
|
||||||
);
|
});
|
||||||
replicas.push(
|
replicas.push(
|
||||||
DrizzlePostgres(replicaPool, {
|
DrizzlePostgres(replicaPool, {
|
||||||
logger: process.env.QUERY_LOGGING == "true"
|
logger: process.env.QUERY_LOGGING == "true"
|
||||||
@@ -91,4 +84,4 @@ export default db;
|
|||||||
export const primaryDb = db.$primary;
|
export const primaryDb = db.$primary;
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool } from "pg";
|
||||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
import { withReplicas } from "drizzle-orm/pg-core";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
||||||
import { createPool } from "./poolConfig";
|
|
||||||
|
|
||||||
function createLogsDb() {
|
function createLogsDb() {
|
||||||
// Only use separate logs database in SaaS builds
|
// Only use separate logs database in SaaS builds
|
||||||
@@ -42,17 +42,12 @@ function createLogsDb() {
|
|||||||
|
|
||||||
// Create separate connection pool for logs database
|
// Create separate connection pool for logs database
|
||||||
const poolConfig = logsConfig?.pool || config.postgres?.pool;
|
const poolConfig = logsConfig?.pool || config.postgres?.pool;
|
||||||
const maxConnections = poolConfig?.max_connections || 20;
|
const primaryPool = new Pool({
|
||||||
const idleTimeoutMs = poolConfig?.idle_timeout_ms || 30000;
|
|
||||||
const connectionTimeoutMs = poolConfig?.connection_timeout_ms || 5000;
|
|
||||||
|
|
||||||
const primaryPool = createPool(
|
|
||||||
connectionString,
|
connectionString,
|
||||||
maxConnections,
|
max: poolConfig?.max_connections || 20,
|
||||||
idleTimeoutMs,
|
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||||
connectionTimeoutMs,
|
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000
|
||||||
"logs-primary"
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const replicas = [];
|
const replicas = [];
|
||||||
|
|
||||||
@@ -63,16 +58,14 @@ function createLogsDb() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const maxReplicaConnections =
|
|
||||||
poolConfig?.max_replica_connections || 20;
|
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replicaPool = createPool(
|
const replicaPool = new Pool({
|
||||||
conn.connection_string,
|
connectionString: conn.connection_string,
|
||||||
maxReplicaConnections,
|
max: poolConfig?.max_replica_connections || 20,
|
||||||
idleTimeoutMs,
|
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||||
connectionTimeoutMs,
|
connectionTimeoutMillis:
|
||||||
"logs-replica"
|
poolConfig?.connection_timeout_ms || 5000
|
||||||
);
|
});
|
||||||
replicas.push(
|
replicas.push(
|
||||||
DrizzlePostgres(replicaPool, {
|
DrizzlePostgres(replicaPool, {
|
||||||
logger: process.env.QUERY_LOGGING == "true"
|
logger: process.env.QUERY_LOGGING == "true"
|
||||||
@@ -91,4 +84,4 @@ function createLogsDb() {
|
|||||||
|
|
||||||
export const logsDb = createLogsDb();
|
export const logsDb = createLogsDb();
|
||||||
export default logsDb;
|
export default logsDb;
|
||||||
export const primaryLogsDb = logsDb.$primary;
|
export const primaryLogsDb = logsDb.$primary;
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Pool, PoolConfig } from "pg";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export function createPoolConfig(
|
|
||||||
connectionString: string,
|
|
||||||
maxConnections: number,
|
|
||||||
idleTimeoutMs: number,
|
|
||||||
connectionTimeoutMs: number
|
|
||||||
): PoolConfig {
|
|
||||||
return {
|
|
||||||
connectionString,
|
|
||||||
max: maxConnections,
|
|
||||||
idleTimeoutMillis: idleTimeoutMs,
|
|
||||||
connectionTimeoutMillis: connectionTimeoutMs,
|
|
||||||
// TCP keepalive to prevent silent connection drops by NAT gateways,
|
|
||||||
// load balancers, and other intermediate network devices (e.g. AWS
|
|
||||||
// NAT Gateway drops idle TCP connections after ~350s)
|
|
||||||
keepAlive: true,
|
|
||||||
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
|
|
||||||
// Allow connections to be released and recreated more aggressively
|
|
||||||
// to avoid stale connections building up
|
|
||||||
allowExitOnIdle: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|
||||||
pool.on("error", (err) => {
|
|
||||||
// This catches errors on idle clients in the pool. Without this
|
|
||||||
// handler an unexpected disconnect would crash the process.
|
|
||||||
logger.error(
|
|
||||||
`Unexpected error on idle ${label} database client: ${err.message}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
pool.on("connect", (client) => {
|
|
||||||
// Set a statement timeout on every new connection so a single slow
|
|
||||||
// query can't block the pool forever
|
|
||||||
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
|
||||||
logger.warn(
|
|
||||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPool(
|
|
||||||
connectionString: string,
|
|
||||||
maxConnections: number,
|
|
||||||
idleTimeoutMs: number,
|
|
||||||
connectionTimeoutMs: number,
|
|
||||||
label: string
|
|
||||||
): Pool {
|
|
||||||
const pool = new Pool(
|
|
||||||
createPoolConfig(
|
|
||||||
connectionString,
|
|
||||||
maxConnections,
|
|
||||||
idleTimeoutMs,
|
|
||||||
connectionTimeoutMs
|
|
||||||
)
|
|
||||||
);
|
|
||||||
attachPoolErrorHandlers(pool, label);
|
|
||||||
return pool;
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
real,
|
real,
|
||||||
serial,
|
serial,
|
||||||
text,
|
text,
|
||||||
|
unique,
|
||||||
varchar
|
varchar
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
@@ -335,9 +336,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
|
||||||
@@ -386,6 +384,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()
|
||||||
@@ -1035,6 +1049,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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -643,9 +649,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"
|
||||||
@@ -700,6 +703,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()
|
||||||
@@ -1134,6 +1153,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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -820,12 +821,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));
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Returns a cached plaintext token from Redis if one exists and decrypts
|
|
||||||
* cleanly, otherwise calls `createSession` to mint a fresh token, stores the
|
|
||||||
* encrypted value in Redis with the given TTL, and returns it.
|
|
||||||
*
|
|
||||||
* Failures at the Redis layer are non-fatal – the function always falls
|
|
||||||
* through to session creation so the caller is never blocked by a Redis outage.
|
|
||||||
*
|
|
||||||
* @param cacheKey Unique Redis key, e.g. `"newt:token_cache:abc123"`
|
|
||||||
* @param secret Server secret used for AES encryption/decryption
|
|
||||||
* @param ttlSeconds Cache TTL in seconds (should match session expiry)
|
|
||||||
* @param createSession Factory that mints a new session and returns its raw token
|
|
||||||
*/
|
|
||||||
export async function getOrCreateCachedToken(
|
|
||||||
cacheKey: string,
|
|
||||||
secret: string,
|
|
||||||
ttlSeconds: number,
|
|
||||||
createSession: () => Promise<string>
|
|
||||||
): Promise<string> {
|
|
||||||
const token = await createSession();
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
@@ -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)));
|
||||||
|
|||||||
22
server/lib/userOrgRoles.ts
Normal file
22
server/lib/userOrgRoles.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ import { rateLimitService } from "#private/lib/rateLimit";
|
|||||||
import { cleanup as wsCleanup } from "#private/routers/ws";
|
import { cleanup as wsCleanup } from "#private/routers/ws";
|
||||||
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
||||||
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
await stopPingAccumulator();
|
|
||||||
await flushBandwidthToDb();
|
await flushBandwidthToDb();
|
||||||
await flushSiteBandwidthToDb();
|
await flushSiteBandwidthToDb();
|
||||||
await rateLimitService.cleanup();
|
await rateLimitService.cleanup();
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 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";
|
import { redisManager } from "@server/private/lib/redis";
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import redisManager from "#private/lib/redis";
|
|
||||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a cached plaintext token from Redis if one exists and decrypts
|
|
||||||
* cleanly, otherwise calls `createSession` to mint a fresh token, stores the
|
|
||||||
* encrypted value in Redis with the given TTL, and returns it.
|
|
||||||
*
|
|
||||||
* Failures at the Redis layer are non-fatal – the function always falls
|
|
||||||
* through to session creation so the caller is never blocked by a Redis outage.
|
|
||||||
*
|
|
||||||
* @param cacheKey Unique Redis key, e.g. `"newt:token_cache:abc123"`
|
|
||||||
* @param secret Server secret used for AES encryption/decryption
|
|
||||||
* @param ttlSeconds Cache TTL in seconds (should match session expiry)
|
|
||||||
* @param createSession Factory that mints a new session and returns its raw token
|
|
||||||
*/
|
|
||||||
export async function getOrCreateCachedToken(
|
|
||||||
cacheKey: string,
|
|
||||||
secret: string,
|
|
||||||
ttlSeconds: number,
|
|
||||||
createSession: () => Promise<string>
|
|
||||||
): Promise<string> {
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
try {
|
|
||||||
const cached = await redisManager.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
const token = decrypt(cached, secret);
|
|
||||||
if (token) {
|
|
||||||
logger.debug(`Token cache hit for key: ${cacheKey}`);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
// Decryption produced an empty string – treat as a miss
|
|
||||||
logger.warn(
|
|
||||||
`Token cache decryption returned empty string for key: ${cacheKey}, treating as miss`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(
|
|
||||||
`Token cache read/decrypt failed for key ${cacheKey}, falling through to session creation:`,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await createSession();
|
|
||||||
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
try {
|
|
||||||
const encrypted = encrypt(token, secret);
|
|
||||||
await redisManager.set(cacheKey, encrypted, ttlSeconds);
|
|
||||||
logger.debug(
|
|
||||||
`Token cached in Redis for key: ${cacheKey} (TTL ${ttlSeconds}s)`
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(
|
|
||||||
`Token cache write failed for key ${cacheKey} (session was still created):`,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ import { z } from "zod";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import {
|
||||||
createRemoteExitNodeSession,
|
createRemoteExitNodeSession,
|
||||||
validateRemoteExitNodeSessionToken,
|
validateRemoteExitNodeSessionToken
|
||||||
EXPIRES
|
|
||||||
} from "#private/auth/sessions/remoteExitNode";
|
} from "#private/auth/sessions/remoteExitNode";
|
||||||
import { getOrCreateCachedToken } from "@server/private/lib/tokenCache";
|
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
@@ -105,23 +103,14 @@ export async function getRemoteExitNodeToken(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a cached token if one exists to prevent thundering herd on
|
const resToken = generateSessionToken();
|
||||||
// simultaneous restarts; falls back to creating a fresh session when
|
await createRemoteExitNodeSession(
|
||||||
// Redis is unavailable or the cache has expired.
|
resToken,
|
||||||
const resToken = await getOrCreateCachedToken(
|
existingRemoteExitNode.remoteExitNodeId
|
||||||
`remote_exit_node:token_cache:${existingRemoteExitNode.remoteExitNodeId}`,
|
|
||||||
config.getRawConfig().server.secret!,
|
|
||||||
Math.floor(EXPIRES / 1000),
|
|
||||||
async () => {
|
|
||||||
const token = generateSessionToken();
|
|
||||||
await createRemoteExitNodeSession(
|
|
||||||
token,
|
|
||||||
existingRemoteExitNode.remoteExitNodeId
|
|
||||||
);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// logger.debug(`Created RemoteExitNode token response: ${JSON.stringify(resToken)}`);
|
||||||
|
|
||||||
return response<{ token: string }>(res, {
|
return response<{ token: string }>(res, {
|
||||||
data: {
|
data: {
|
||||||
token: resToken
|
token: resToken
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
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 { 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 "@server/lib/sshCA";
|
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
@@ -125,7 +125,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(
|
||||||
@@ -133,6 +133,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)
|
||||||
@@ -339,7 +348,7 @@ export async function signSshKey(
|
|||||||
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) {
|
||||||
@@ -351,28 +360,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
|
||||||
|
|||||||
@@ -19,14 +19,17 @@ import { Socket } from "net";
|
|||||||
import {
|
import {
|
||||||
Newt,
|
Newt,
|
||||||
newts,
|
newts,
|
||||||
Olm,
|
NewtSession,
|
||||||
olms,
|
olms,
|
||||||
|
Olm,
|
||||||
|
OlmSession,
|
||||||
RemoteExitNode,
|
RemoteExitNode,
|
||||||
|
RemoteExitNodeSession,
|
||||||
remoteExitNodes,
|
remoteExitNodes,
|
||||||
|
sites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { recordPing } from "@server/routers/newt/pingAccumulator";
|
|
||||||
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
||||||
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
|
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -194,7 +197,11 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
|||||||
// Config version tracking map (local to this node, resets on server restart)
|
// Config version tracking map (local to this node, resets on server restart)
|
||||||
const clientConfigVersions: Map<string, number> = new Map();
|
const clientConfigVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
// Tracks the last Unix timestamp (seconds) at which a ping was flushed to the
|
||||||
|
// DB for a given siteId. Resets on server restart which is fine – the first
|
||||||
|
// ping after startup will always write, re-establishing the online state.
|
||||||
|
const lastPingDbWrite: Map<number, number> = new Map();
|
||||||
|
const PING_DB_WRITE_INTERVAL = 45; // seconds
|
||||||
|
|
||||||
// Recovery tracking
|
// Recovery tracking
|
||||||
let isRedisRecoveryInProgress = false;
|
let isRedisRecoveryInProgress = false;
|
||||||
@@ -846,16 +853,32 @@ const setupConnection = async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle WebSocket protocol-level pings from older newt clients that do
|
||||||
|
// not send application-level "newt/ping" messages. Update the site's
|
||||||
|
// online state and lastPing timestamp so the offline checker treats them
|
||||||
|
// the same as modern newt clients.
|
||||||
if (clientType === "newt") {
|
if (clientType === "newt") {
|
||||||
const newtClient = client as Newt;
|
const newtClient = client as Newt;
|
||||||
ws.on("ping", () => {
|
ws.on("ping", async () => {
|
||||||
if (!newtClient.siteId) return;
|
if (!newtClient.siteId) return;
|
||||||
// Record the ping in the accumulator instead of writing to the
|
const now = Math.floor(Date.now() / 1000);
|
||||||
// database on every WS ping frame. The accumulator flushes all
|
const lastWrite = lastPingDbWrite.get(newtClient.siteId) ?? 0;
|
||||||
// pending pings in a single batched UPDATE every ~10s, which
|
if (now - lastWrite < PING_DB_WRITE_INTERVAL) return;
|
||||||
// prevents connection pool exhaustion under load (especially
|
lastPingDbWrite.set(newtClient.siteId, now);
|
||||||
// with cross-region latency to the database).
|
try {
|
||||||
recordPing(newtClient.siteId);
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: true,
|
||||||
|
lastPing: now
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newtClient.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Error updating newt site online state on WS ping",
|
||||||
|
{ error }
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -598,6 +598,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,
|
||||||
|
|||||||
@@ -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")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { db, newtSessions } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { newts } from "@server/db";
|
import { newts } from "@server/db";
|
||||||
import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache";
|
|
||||||
import { EXPIRES } from "@server/auth/sessions/newt";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -94,19 +92,8 @@ export async function getNewtToken(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a cached token if one exists to prevent thundering herd on
|
const resToken = generateSessionToken();
|
||||||
// simultaneous restarts; falls back to creating a fresh session when
|
await createNewtSession(resToken, existingNewt.newtId);
|
||||||
// Redis is unavailable or the cache has expired.
|
|
||||||
const resToken = await getOrCreateCachedToken(
|
|
||||||
`newt:token_cache:${existingNewt.newtId}`,
|
|
||||||
config.getRawConfig().server.secret!,
|
|
||||||
Math.floor(EXPIRES / 1000),
|
|
||||||
async () => {
|
|
||||||
const token = generateSessionToken();
|
|
||||||
await createNewtSession(token, existingNewt.newtId);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return response<{ token: string; serverVersion: string }>(res, {
|
return response<{ token: string; serverVersion: string }>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Newt } from "@server/db";
|
|||||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sendNewtSyncMessage } from "./sync";
|
import { sendNewtSyncMessage } from "./sync";
|
||||||
import { recordPing } from "./pingAccumulator";
|
|
||||||
|
|
||||||
// Track if the offline checker interval is running
|
// Track if the offline checker interval is running
|
||||||
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||||
@@ -115,12 +114,18 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the ping in memory; it will be flushed to the database
|
try {
|
||||||
// periodically by the ping accumulator (every ~10s) in a single
|
// Mark the site as online and record the ping timestamp.
|
||||||
// batched UPDATE instead of one query per ping. This prevents
|
await db
|
||||||
// connection pool exhaustion under load, especially with
|
.update(sites)
|
||||||
// cross-region latency to the database.
|
.set({
|
||||||
recordPing(newt.siteId);
|
online: true,
|
||||||
|
lastPing: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating online state on newt ping", { error });
|
||||||
|
}
|
||||||
|
|
||||||
// Check config version and sync if stale.
|
// Check config version and sync if stale.
|
||||||
const configVersion = await getClientConfigVersion(newt.newtId);
|
const configVersion = await getClientConfigVersion(newt.newtId);
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
import { db } from "@server/db";
|
|
||||||
import { sites, clients, olms } from "@server/db";
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ping Accumulator
|
|
||||||
*
|
|
||||||
* Instead of writing to the database on every single newt/olm ping (which
|
|
||||||
* causes pool exhaustion under load, especially with cross-region latency),
|
|
||||||
* we accumulate pings in memory and flush them to the database periodically
|
|
||||||
* in a single batch.
|
|
||||||
*
|
|
||||||
* This is the same pattern used for bandwidth flushing in
|
|
||||||
* receiveBandwidth.ts and handleReceiveBandwidthMessage.ts.
|
|
||||||
*
|
|
||||||
* Supports two kinds of pings:
|
|
||||||
* - **Site pings** (from newts): update `sites.online` and `sites.lastPing`
|
|
||||||
* - **Client pings** (from OLMs): update `clients.online`, `clients.lastPing`,
|
|
||||||
* `clients.archived`, and optionally reset `olms.archived`
|
|
||||||
*/
|
|
||||||
|
|
||||||
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
|
||||||
const MAX_RETRIES = 2;
|
|
||||||
const BASE_DELAY_MS = 50;
|
|
||||||
|
|
||||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
|
||||||
// Map of siteId -> latest ping timestamp (unix seconds)
|
|
||||||
const pendingSitePings: Map<number, number> = new Map();
|
|
||||||
|
|
||||||
// ── Client (OLM) pings ────────────────────────────────────────────────
|
|
||||||
// Map of clientId -> latest ping timestamp (unix seconds)
|
|
||||||
const pendingClientPings: Map<number, number> = new Map();
|
|
||||||
// Set of olmIds whose `archived` flag should be reset to false
|
|
||||||
const pendingOlmArchiveResets: Set<string> = new Set();
|
|
||||||
|
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
// ── Public API ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a ping for a newt site. This does NOT write to the database
|
|
||||||
* immediately. Instead it stores the latest ping timestamp in memory,
|
|
||||||
* to be flushed periodically by the background timer.
|
|
||||||
*/
|
|
||||||
export function recordSitePing(siteId: number): void {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
pendingSitePings.set(siteId, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Use `recordSitePing` instead. Alias kept for existing call-sites. */
|
|
||||||
export const recordPing = recordSitePing;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a ping for an OLM client. Batches the `clients` table update
|
|
||||||
* (`online`, `lastPing`, `archived`) and, when `olmArchived` is true,
|
|
||||||
* also queues an `olms` table update to clear the archived flag.
|
|
||||||
*/
|
|
||||||
export function recordClientPing(
|
|
||||||
clientId: number,
|
|
||||||
olmId: string,
|
|
||||||
olmArchived: boolean
|
|
||||||
): void {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
pendingClientPings.set(clientId, now);
|
|
||||||
if (olmArchived) {
|
|
||||||
pendingOlmArchiveResets.add(olmId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Flush Logic ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush all accumulated site pings to the database.
|
|
||||||
*/
|
|
||||||
async function flushSitePingsToDb(): Promise<void> {
|
|
||||||
if (pendingSitePings.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot and clear so new pings arriving during the flush go into a
|
|
||||||
// fresh map for the next cycle.
|
|
||||||
const pingsToFlush = new Map(pendingSitePings);
|
|
||||||
pendingSitePings.clear();
|
|
||||||
|
|
||||||
// Sort by siteId for consistent lock ordering (prevents deadlocks)
|
|
||||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
|
||||||
([a], [b]) => a - b
|
|
||||||
);
|
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
|
||||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
|
||||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withRetry(async () => {
|
|
||||||
// Group by timestamp for efficient bulk updates
|
|
||||||
const byTimestamp = new Map<number, number[]>();
|
|
||||||
for (const [siteId, timestamp] of batch) {
|
|
||||||
const group = byTimestamp.get(timestamp) || [];
|
|
||||||
group.push(siteId);
|
|
||||||
byTimestamp.set(timestamp, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (byTimestamp.size === 1) {
|
|
||||||
const [timestamp, siteIds] = Array.from(
|
|
||||||
byTimestamp.entries()
|
|
||||||
)[0];
|
|
||||||
await db
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
online: true,
|
|
||||||
lastPing: timestamp
|
|
||||||
})
|
|
||||||
.where(inArray(sites.siteId, siteIds));
|
|
||||||
} else {
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
for (const [timestamp, siteIds] of byTimestamp) {
|
|
||||||
await tx
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
online: true,
|
|
||||||
lastPing: timestamp
|
|
||||||
})
|
|
||||||
.where(inArray(sites.siteId, siteIds));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, "flushSitePingsToDb");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
|
|
||||||
{ error }
|
|
||||||
);
|
|
||||||
for (const [siteId, timestamp] of batch) {
|
|
||||||
const existing = pendingSitePings.get(siteId);
|
|
||||||
if (!existing || existing < timestamp) {
|
|
||||||
pendingSitePings.set(siteId, timestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush all accumulated client (OLM) pings to the database.
|
|
||||||
*/
|
|
||||||
async function flushClientPingsToDb(): Promise<void> {
|
|
||||||
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot and clear
|
|
||||||
const pingsToFlush = new Map(pendingClientPings);
|
|
||||||
pendingClientPings.clear();
|
|
||||||
|
|
||||||
const olmResetsToFlush = new Set(pendingOlmArchiveResets);
|
|
||||||
pendingOlmArchiveResets.clear();
|
|
||||||
|
|
||||||
// ── Flush client pings ─────────────────────────────────────────────
|
|
||||||
if (pingsToFlush.size > 0) {
|
|
||||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
|
||||||
([a], [b]) => a - b
|
|
||||||
);
|
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
|
||||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
|
||||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withRetry(async () => {
|
|
||||||
const byTimestamp = new Map<number, number[]>();
|
|
||||||
for (const [clientId, timestamp] of batch) {
|
|
||||||
const group = byTimestamp.get(timestamp) || [];
|
|
||||||
group.push(clientId);
|
|
||||||
byTimestamp.set(timestamp, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (byTimestamp.size === 1) {
|
|
||||||
const [timestamp, clientIds] = Array.from(
|
|
||||||
byTimestamp.entries()
|
|
||||||
)[0];
|
|
||||||
await db
|
|
||||||
.update(clients)
|
|
||||||
.set({
|
|
||||||
lastPing: timestamp,
|
|
||||||
online: true,
|
|
||||||
archived: false
|
|
||||||
})
|
|
||||||
.where(inArray(clients.clientId, clientIds));
|
|
||||||
} else {
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
for (const [timestamp, clientIds] of byTimestamp) {
|
|
||||||
await tx
|
|
||||||
.update(clients)
|
|
||||||
.set({
|
|
||||||
lastPing: timestamp,
|
|
||||||
online: true,
|
|
||||||
archived: false
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
inArray(clients.clientId, clientIds)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, "flushClientPingsToDb");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to flush client ping batch (${batch.length} clients), re-queuing for next cycle`,
|
|
||||||
{ error }
|
|
||||||
);
|
|
||||||
for (const [clientId, timestamp] of batch) {
|
|
||||||
const existing = pendingClientPings.get(clientId);
|
|
||||||
if (!existing || existing < timestamp) {
|
|
||||||
pendingClientPings.set(clientId, timestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Flush OLM archive resets ───────────────────────────────────────
|
|
||||||
if (olmResetsToFlush.size > 0) {
|
|
||||||
const olmIds = Array.from(olmResetsToFlush).sort();
|
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
|
||||||
for (let i = 0; i < olmIds.length; i += BATCH_SIZE) {
|
|
||||||
const batch = olmIds.slice(i, i + BATCH_SIZE);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withRetry(async () => {
|
|
||||||
await db
|
|
||||||
.update(olms)
|
|
||||||
.set({ archived: false })
|
|
||||||
.where(inArray(olms.olmId, batch));
|
|
||||||
}, "flushOlmArchiveResets");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to flush OLM archive reset batch (${batch.length} olms), re-queuing for next cycle`,
|
|
||||||
{ error }
|
|
||||||
);
|
|
||||||
for (const olmId of batch) {
|
|
||||||
pendingOlmArchiveResets.add(olmId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush everything — called by the interval timer and during shutdown.
|
|
||||||
*/
|
|
||||||
export async function flushPingsToDb(): Promise<void> {
|
|
||||||
await flushSitePingsToDb();
|
|
||||||
await flushClientPingsToDb();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Retry / Error Helpers ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
|
||||||
* (connection timeouts, unexpected disconnects).
|
|
||||||
*/
|
|
||||||
async function withRetry<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
context: string
|
|
||||||
): Promise<T> {
|
|
||||||
let attempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (isTransientError(error) && attempt < MAX_RETRIES) {
|
|
||||||
attempt++;
|
|
||||||
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
|
|
||||||
const jitter = Math.random() * baseDelay;
|
|
||||||
const delay = baseDelay + jitter;
|
|
||||||
logger.warn(
|
|
||||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect transient connection errors that are safe to retry.
|
|
||||||
*/
|
|
||||||
function isTransientError(error: any): boolean {
|
|
||||||
if (!error) return false;
|
|
||||||
|
|
||||||
const message = (error.message || "").toLowerCase();
|
|
||||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
|
||||||
const code = error.code || "";
|
|
||||||
|
|
||||||
// Connection timeout / terminated
|
|
||||||
if (
|
|
||||||
message.includes("connection timeout") ||
|
|
||||||
message.includes("connection terminated") ||
|
|
||||||
message.includes("timeout exceeded when trying to connect") ||
|
|
||||||
causeMessage.includes("connection terminated unexpectedly") ||
|
|
||||||
causeMessage.includes("connection timeout")
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL deadlock
|
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE
|
|
||||||
if (
|
|
||||||
code === "ECONNRESET" ||
|
|
||||||
code === "ECONNREFUSED" ||
|
|
||||||
code === "EPIPE" ||
|
|
||||||
code === "ETIMEDOUT"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the background flush timer. Call this once at server startup.
|
|
||||||
*/
|
|
||||||
export function startPingAccumulator(): void {
|
|
||||||
if (flushTimer) {
|
|
||||||
return; // Already running
|
|
||||||
}
|
|
||||||
|
|
||||||
flushTimer = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await flushPingsToDb();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Unhandled error in ping accumulator flush", {
|
|
||||||
error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, FLUSH_INTERVAL_MS);
|
|
||||||
|
|
||||||
// Don't prevent the process from exiting
|
|
||||||
flushTimer.unref();
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Ping accumulator started (flush interval: ${FLUSH_INTERVAL_MS}ms)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the background flush timer and perform a final flush.
|
|
||||||
* Call this during graceful shutdown.
|
|
||||||
*/
|
|
||||||
export async function stopPingAccumulator(): Promise<void> {
|
|
||||||
if (flushTimer) {
|
|
||||||
clearInterval(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final flush to persist any remaining pings
|
|
||||||
try {
|
|
||||||
await flushPingsToDb();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error during final ping accumulator flush", { error });
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Ping accumulator stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of pending (unflushed) pings. Useful for monitoring.
|
|
||||||
*/
|
|
||||||
export function getPendingPingCount(): number {
|
|
||||||
return pendingSitePings.size + pendingClientPings.size;
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
ExitNode,
|
ExitNode,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
sites,
|
sites,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { olms } from "@server/db";
|
import { olms } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -20,10 +20,8 @@ import { z } from "zod";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import {
|
import {
|
||||||
createOlmSession,
|
createOlmSession,
|
||||||
validateOlmSessionToken,
|
validateOlmSessionToken
|
||||||
EXPIRES
|
|
||||||
} from "@server/auth/sessions/olm";
|
} from "@server/auth/sessions/olm";
|
||||||
import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache";
|
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
@@ -134,19 +132,8 @@ export async function getOlmToken(
|
|||||||
|
|
||||||
logger.debug("Creating new olm session token");
|
logger.debug("Creating new olm session token");
|
||||||
|
|
||||||
// Return a cached token if one exists to prevent thundering herd on
|
const resToken = generateSessionToken();
|
||||||
// simultaneous restarts; falls back to creating a fresh session when
|
await createOlmSession(resToken, existingOlm.olmId);
|
||||||
// Redis is unavailable or the cache has expired.
|
|
||||||
const resToken = await getOrCreateCachedToken(
|
|
||||||
`olm:token_cache:${existingOlm.olmId}`,
|
|
||||||
config.getRawConfig().server.secret!,
|
|
||||||
Math.floor(EXPIRES / 1000),
|
|
||||||
async () => {
|
|
||||||
const token = generateSessionToken();
|
|
||||||
await createOlmSession(token, existingOlm.olmId);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let clientIdToUse;
|
let clientIdToUse;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { db } from "@server/db";
|
|||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { clients, olms, Olm } from "@server/db";
|
import { clients, olms, Olm } from "@server/db";
|
||||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||||
import { recordClientPing } from "@server/routers/newt/pingAccumulator";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
@@ -202,12 +201,22 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
|
|||||||
await sendOlmSyncMessage(olm, client);
|
await sendOlmSyncMessage(olm, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the ping in memory; it will be flushed to the database
|
// Update the client's last ping timestamp
|
||||||
// periodically by the ping accumulator (every ~10s) in a single
|
await db
|
||||||
// batched UPDATE instead of one query per ping. This prevents
|
.update(clients)
|
||||||
// connection pool exhaustion under load, especially with
|
.set({
|
||||||
// cross-region latency to the database.
|
lastPing: Math.floor(Date.now() / 1000),
|
||||||
recordClientPing(olm.clientId, olm.olmId, !!olm.archived);
|
online: true,
|
||||||
|
archived: false
|
||||||
|
})
|
||||||
|
.where(eq(clients.clientId, olm.clientId));
|
||||||
|
|
||||||
|
if (olm.archived) {
|
||||||
|
await db
|
||||||
|
.update(olms)
|
||||||
|
.set({ archived: false })
|
||||||
|
.where(eq(olms.olmId, olm.olmId));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling ping message", { error });
|
logger.error("Error handling ping message", { error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
);
|
);
|
||||||
@@ -292,7 +292,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!,
|
||||||
@@ -385,7 +385,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!,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -305,7 +305,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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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!,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
export async function queryUser(orgId: string, userId: string) {
|
export 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 @@ export 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 @@ export 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<
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
157
server/routers/user/removeUserRole.ts
Normal file
157
server/routers/user/removeUserRole.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
startNewtOfflineChecker,
|
startNewtOfflineChecker,
|
||||||
handleNewtDisconnectingMessage
|
handleNewtDisconnectingMessage
|
||||||
} from "../newt";
|
} from "../newt";
|
||||||
import { startPingAccumulator } from "../newt/pingAccumulator";
|
|
||||||
import {
|
import {
|
||||||
handleOlmRegisterMessage,
|
handleOlmRegisterMessage,
|
||||||
handleOlmRelayMessage,
|
handleOlmRelayMessage,
|
||||||
@@ -47,10 +46,6 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
"ws/round-trip/complete": handleRoundTripMessage
|
"ws/round-trip/complete": handleRoundTripMessage
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start the ping accumulator for all builds — it batches per-site online/lastPing
|
|
||||||
// updates into periodic bulk writes, preventing connection pool exhaustion.
|
|
||||||
startPingAccumulator();
|
|
||||||
|
|
||||||
if (build != "saas") {
|
if (build != "saas") {
|
||||||
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||||
startNewtOfflineChecker(); // this is to handle the offline check for newts
|
startNewtOfflineChecker(); // this is to handle the offline check for newts
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Socket } from "net";
|
|||||||
import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db";
|
import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { recordPing } from "@server/routers/newt/pingAccumulator";
|
|
||||||
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
||||||
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
|
import { validateOlmSessionToken } from "@server/auth/sessions/olm";
|
||||||
import { messageHandlers } from "./messageHandlers";
|
import { messageHandlers } from "./messageHandlers";
|
||||||
@@ -387,14 +386,22 @@ const setupConnection = async (
|
|||||||
// the same as modern newt clients.
|
// the same as modern newt clients.
|
||||||
if (clientType === "newt") {
|
if (clientType === "newt") {
|
||||||
const newtClient = client as Newt;
|
const newtClient = client as Newt;
|
||||||
ws.on("ping", () => {
|
ws.on("ping", async () => {
|
||||||
if (!newtClient.siteId) return;
|
if (!newtClient.siteId) return;
|
||||||
// Record the ping in the accumulator instead of writing to the
|
try {
|
||||||
// database on every WS ping frame. The accumulator flushes all
|
await db
|
||||||
// pending pings in a single batched UPDATE every ~10s, which
|
.update(sites)
|
||||||
// prevents connection pool exhaustion under load (especially
|
.set({
|
||||||
// with cross-region latency to the database).
|
online: true,
|
||||||
recordPing(newtClient.siteId);
|
lastPing: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newtClient.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Error updating newt site online state on WS ping",
|
||||||
|
{ error }
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,8 +275,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabled = !isPaidUser(tierMatrix.orgOidc);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -294,9 +292,6 @@ export default function Page() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.orgOidc} />
|
|
||||||
|
|
||||||
<fieldset disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""}>
|
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
@@ -817,10 +812,9 @@ export default function Page() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createLoading || disabled}
|
disabled={createLoading || !isPaidUser(tierMatrix.orgOidc)}
|
||||||
loading={createLoading}
|
loading={createLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (disabled) return;
|
|
||||||
// log any issues with the form
|
// log any issues with the form
|
||||||
console.log(form.formState.errors);
|
console.log(form.formState.errors);
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
@@ -829,7 +823,6 @@ export default function Page() {
|
|||||||
{t("idpSubmit")}
|
{t("idpSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user