feat: add accessedServers permission handling and server access validation

- Introduced `accessedServers` field in user permissions schema and member table.
- Implemented server access validation across various API routers to ensure users can only access permitted servers.
- Added a new query to fetch accessible server IDs based on user roles and licenses.
- Updated UI components to support server selection in user permissions.
This commit is contained in:
Mauricio Siu
2026-04-05 00:06:27 -06:00
parent c160f24765
commit bfa4ebc801
17 changed files with 8595 additions and 5 deletions
@@ -172,6 +172,7 @@ const addPermissions = z.object({
accessedEnvironments: z.array(z.string()).optional(), accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(), accessedServices: z.array(z.string()).optional(),
accessedGitProviders: z.array(z.string()).optional(), accessedGitProviders: z.array(z.string()).optional(),
accessedServers: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false), canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false), canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false), canDeleteProjects: z.boolean().optional().default(false),
@@ -208,6 +209,13 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
}, },
); );
const { data: servers } = api.server.allForPermissions.useQuery(
undefined,
{
enabled: isOpen && !!haveValidLicense,
},
);
const { data, refetch } = api.user.one.useQuery( const { data, refetch } = api.user.one.useQuery(
{ {
userId, userId,
@@ -226,6 +234,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedEnvironments: [], accessedEnvironments: [],
accessedServices: [], accessedServices: [],
accessedGitProviders: [], accessedGitProviders: [],
accessedServers: [],
canDeleteEnvironments: false, canDeleteEnvironments: false,
canCreateProjects: false, canCreateProjects: false,
canCreateServices: false, canCreateServices: false,
@@ -248,6 +257,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedEnvironments: data.accessedEnvironments || [], accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [], accessedServices: data.accessedServices || [],
accessedGitProviders: data.accessedGitProviders || [], accessedGitProviders: data.accessedGitProviders || [],
accessedServers: data.accessedServers || [],
canCreateProjects: data.canCreateProjects, canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices, canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects, canDeleteProjects: data.canDeleteProjects,
@@ -276,6 +286,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
accessedEnvironments: data.accessedEnvironments || [], accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [], accessedServices: data.accessedServices || [],
accessedGitProviders: data.accessedGitProviders || [], accessedGitProviders: data.accessedGitProviders || [],
accessedServers: data.accessedServers || [],
canAccessToDocker: data.canAccessToDocker, canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI, canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys, canAccessToSSHKeys: data.canAccessToSSHKeys,
@@ -956,6 +967,81 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
/> />
</div> </div>
)} )}
{haveValidLicense ? (
<FormField
control={form.control}
name="accessedServers"
render={() => (
<FormItem className="md:col-span-2">
<div className="mb-4">
<FormLabel className="text-base">Servers</FormLabel>
<FormDescription>
Select the Servers that the user can access
</FormDescription>
</div>
{servers?.length === 0 && (
<p className="text-sm text-muted-foreground">
No servers found
</p>
)}
<div className="grid md:grid-cols-1 gap-2">
{servers?.map((s) => (
<FormField
key={s.serverId}
control={form.control}
name="accessedServers"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-lg border p-3">
<FormControl>
<Checkbox
checked={field.value?.includes(
s.serverId,
)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([
...(field.value || []),
s.serverId,
]);
} else {
field.onChange(
field.value?.filter(
(v) => v !== s.serverId,
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<FormLabel className="text-sm cursor-pointer">
{s.name}
</FormLabel>
<span className="text-xs text-muted-foreground">
({s.ipAddress})
</span>
<span className="text-xs text-muted-foreground capitalize">
{s.serverType}
</span>
</div>
</FormItem>
)}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
) : (
<div className="md:col-span-2">
<EnterpriseFeatureLocked
compact
title="Server Assignment"
description="Assign specific Servers to users with an Enterprise license."
/>
</div>
)}
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2"> <DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
<Button <Button
isLoading={isPending} isLoading={isPending}
@@ -0,0 +1 @@
ALTER TABLE "member" ADD COLUMN "accessedServers" text[] DEFAULT ARRAY[]::text[] NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1142,6 +1142,13 @@
"when": 1775348977418, "when": 1775348977418,
"tag": "0162_happy_alex_wilder", "tag": "0162_happy_alex_wilder",
"breakpoints": true "breakpoints": true
},
{
"idx": 163,
"version": "7",
"when": 1775367585821,
"tag": "0163_perfect_lethal_legion",
"breakpoints": true
} }
] ]
} }
@@ -26,6 +26,7 @@ import {
updateDeploymentStatus, updateDeploymentStatus,
writeConfig, writeConfig,
writeConfigRemote, writeConfigRemote,
getAccessibleServerIds,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import {
@@ -99,6 +100,16 @@ export const applicationRouter = createTRPCRouter({
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const newApplication = await createApplication(input); const newApplication = await createApplication(input);
await addNewService(ctx, newApplication.applicationId); await addNewService(ctx, newApplication.applicationId);
@@ -630,6 +641,17 @@ export const applicationRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, input.applicationId, { await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"], service: ["create"],
}); });
if (input.buildServerId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.buildServerId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this build server",
});
}
}
const { applicationId, ...rest } = input; const { applicationId, ...rest } = input;
const updateApp = await updateApplication(applicationId, { const updateApp = await updateApplication(applicationId, {
...rest, ...rest,
@@ -30,6 +30,7 @@ import {
stopCompose, stopCompose,
updateCompose, updateCompose,
updateDeploymentStatus, updateDeploymentStatus,
getAccessibleServerIds,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import {
@@ -100,6 +101,17 @@ export const composeRouter = createTRPCRouter({
message: "You are not authorized to access this project", message: "You are not authorized to access this project",
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const newService = await createCompose({ const newService = await createCompose({
...input, ...input,
}); });
@@ -553,6 +565,16 @@ export const composeRouter = createTRPCRouter({
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const template = await fetchTemplateFiles(input.id, input.baseUrl); const template = await fetchTemplateFiles(input.id, input.baseUrl);
let serverIp = "127.0.0.1"; let serverIp = "127.0.0.1";
+12
View File
@@ -15,6 +15,7 @@ import {
stopService, stopService,
stopServiceRemote, stopServiceRemote,
updateLibsqlById, updateLibsqlById,
getAccessibleServerIds,
} from "@dokploy/server"; } from "@dokploy/server";
import { import {
addNewService, addNewService,
@@ -62,6 +63,17 @@ export const libsqlRouter = createTRPCRouter({
message: "You are not authorized to access this project", message: "You are not authorized to access this project",
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const newLibsql = await createLibsql({ const newLibsql = await createLibsql({
...input, ...input,
}); });
@@ -19,6 +19,7 @@ import {
stopService, stopService,
stopServiceRemote, stopServiceRemote,
updateMariadbById, updateMariadbById,
getAccessibleServerIds,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import {
@@ -73,6 +74,17 @@ export const mariadbRouter = createTRPCRouter({
message: "You are not authorized to access this project", message: "You are not authorized to access this project",
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const newMariadb = await createMariadb({ const newMariadb = await createMariadb({
...input, ...input,
}); });
+12
View File
@@ -9,6 +9,7 @@ import {
findEnvironmentById, findEnvironmentById,
findMongoById, findMongoById,
findProjectById, findProjectById,
getAccessibleServerIds,
getServiceContainerCommand, getServiceContainerCommand,
IS_CLOUD, IS_CLOUD,
rebuildDatabase, rebuildDatabase,
@@ -72,6 +73,17 @@ export const mongoRouter = createTRPCRouter({
message: "You are not authorized to access this project", message: "You are not authorized to access this project",
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const newMongo = await createMongo({ const newMongo = await createMongo({
...input, ...input,
}); });
+11
View File
@@ -19,6 +19,7 @@ import {
stopService, stopService,
stopServiceRemote, stopServiceRemote,
updateMySqlById, updateMySqlById,
getAccessibleServerIds,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import {
@@ -74,6 +75,16 @@ export const mysqlRouter = createTRPCRouter({
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const newMysql = await createMysql({ const newMysql = await createMysql({
...input, ...input,
}); });
@@ -20,6 +20,7 @@ import {
stopService, stopService,
stopServiceRemote, stopServiceRemote,
updatePostgresById, updatePostgresById,
getAccessibleServerIds,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import {
@@ -74,6 +75,17 @@ export const postgresRouter = createTRPCRouter({
message: "You are not authorized to access this project", message: "You are not authorized to access this project",
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const newPostgres = await createPostgres({ const newPostgres = await createPostgres({
...input, ...input,
}); });
+12
View File
@@ -18,6 +18,7 @@ import {
stopService, stopService,
stopServiceRemote, stopServiceRemote,
updateRedisById, updateRedisById,
getAccessibleServerIds,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import {
@@ -70,6 +71,17 @@ export const redisRouter = createTRPCRouter({
message: "You are not authorized to access this project", message: "You are not authorized to access this project",
}); });
} }
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const newRedis = await createRedis({ const newRedis = await createRedis({
...input, ...input,
}); });
+42 -3
View File
@@ -14,8 +14,10 @@ import {
serverValidate, serverValidate,
setupMonitoring, setupMonitoring,
updateServerById, updateServerById,
getAccessibleServerIds,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { and, desc, eq, getTableColumns, isNotNull, sql } from "drizzle-orm"; import { and, desc, eq, getTableColumns, isNotNull, sql } from "drizzle-orm";
@@ -88,6 +90,14 @@ export const serverRouter = createTRPCRouter({
}); });
} }
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
return server; return server;
}), }),
getDefaultCommand: withPermission("server", "read") getDefaultCommand: withPermission("server", "read")
@@ -98,6 +108,8 @@ export const serverRouter = createTRPCRouter({
return defaultCommand(isBuildServer); return defaultCommand(isBuildServer);
}), }),
all: withPermission("server", "read").query(async ({ ctx }) => { all: withPermission("server", "read").query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db const result = await db
.select({ .select({
...getTableColumns(server), ...getTableColumns(server),
@@ -115,8 +127,31 @@ export const serverRouter = createTRPCRouter({
.orderBy(desc(server.createdAt)) .orderBy(desc(server.createdAt))
.groupBy(server.serverId); .groupBy(server.serverId);
return result; return result.filter((s) => accessibleIds.has(s.serverId));
}), }),
allForPermissions: withPermission("member", "update")
.use(async ({ ctx, next }) => {
const licensed = await hasValidLicense(ctx.session.activeOrganizationId);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",
});
}
return next();
})
.query(async ({ ctx }) => {
return await db.query.server.findMany({
columns: {
serverId: true,
name: true,
ipAddress: true,
serverType: true,
},
orderBy: desc(server.createdAt),
where: eq(server.organizationId, ctx.session.activeOrganizationId),
});
}),
count: protectedProcedure.query(async ({ ctx }) => { count: protectedProcedure.query(async ({ ctx }) => {
const organizations = await db.query.organization.findMany({ const organizations = await db.query.organization.findMany({
where: eq(organization.ownerId, ctx.user.id), where: eq(organization.ownerId, ctx.user.id),
@@ -130,6 +165,8 @@ export const serverRouter = createTRPCRouter({
return servers.length ?? 0; return servers.length ?? 0;
}), }),
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => { withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({ const result = await db.query.server.findMany({
orderBy: desc(server.createdAt), orderBy: desc(server.createdAt),
where: IS_CLOUD where: IS_CLOUD
@@ -145,9 +182,11 @@ export const serverRouter = createTRPCRouter({
eq(server.serverType, "deploy"), eq(server.serverType, "deploy"),
), ),
}); });
return result; return result.filter((s) => accessibleIds.has(s.serverId));
}), }),
buildServers: withPermission("server", "read").query(async ({ ctx }) => { buildServers: withPermission("server", "read").query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({ const result = await db.query.server.findMany({
orderBy: desc(server.createdAt), orderBy: desc(server.createdAt),
where: IS_CLOUD where: IS_CLOUD
@@ -163,7 +202,7 @@ export const serverRouter = createTRPCRouter({
eq(server.serverType, "build"), eq(server.serverType, "build"),
), ),
}); });
return result; return result.filter((s) => accessibleIds.has(s.serverId));
}), }),
setup: withPermission("server", "create") setup: withPermission("server", "create")
.input(apiFindOneServer) .input(apiFindOneServer)
+4 -1
View File
@@ -347,7 +347,7 @@ export const userRouter = createTRPCRouter({
}); });
} }
const { id, accessedGitProviders, ...rest } = input; const { id, accessedGitProviders, accessedServers, ...rest } = input;
const licensed = await hasValidLicense( const licensed = await hasValidLicense(
ctx.session?.activeOrganizationId || "", ctx.session?.activeOrganizationId || "",
@@ -360,6 +360,9 @@ export const userRouter = createTRPCRouter({
...(licensed && accessedGitProviders !== undefined ...(licensed && accessedGitProviders !== undefined
? { accessedGitProviders } ? { accessedGitProviders }
: {}), : {}),
...(licensed && accessedServers !== undefined
? { accessedServers }
: {}),
}) })
.where( .where(
and( and(
+4
View File
@@ -167,6 +167,10 @@ export const member = pgTable("member", {
.array() .array()
.notNull() .notNull()
.default(sql`ARRAY[]::text[]`), .default(sql`ARRAY[]::text[]`),
accessedServers: text("accessedServers")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
}); });
export const memberRelations = relations(member, ({ one }) => ({ export const memberRelations = relations(member, ({ one }) => ({
+1
View File
@@ -131,6 +131,7 @@ export const apiAssignPermissions = createSchema
accessedEnvironments: z.array(z.string()).optional(), accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(), accessedServices: z.array(z.string()).optional(),
accessedGitProviders: z.array(z.string()).optional(), accessedGitProviders: z.array(z.string()).optional(),
accessedServers: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional(), canCreateProjects: z.boolean().optional(),
canCreateServices: z.boolean().optional(), canCreateServices: z.boolean().optional(),
canDeleteProjects: z.boolean().optional(), canDeleteProjects: z.boolean().optional(),
+37 -1
View File
@@ -1,11 +1,13 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import {
type apiCreateServer, type apiCreateServer,
member,
organization, organization,
server, server,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { z } from "zod"; import type { z } from "zod";
export type Server = typeof server.$inferSelect; export type Server = typeof server.$inferSelect;
@@ -130,3 +132,37 @@ export const getAllServers = async () => {
const servers = await db.query.server.findMany(); const servers = await db.query.server.findMany();
return servers; return servers;
}; };
export const getAccessibleServerIds = async (session: {
userId: string;
activeOrganizationId: string;
}): Promise<Set<string>> => {
const { userId, activeOrganizationId } = session;
const allOrgServers = await db.query.server.findMany({
where: eq(server.organizationId, activeOrganizationId),
columns: {
serverId: true,
},
});
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { accessedServers: true, role: true },
});
if (memberRecord?.role === "owner" || memberRecord?.role === "admin") {
return new Set(allOrgServers.map((s) => s.serverId));
}
const licensed = await hasValidLicense(activeOrganizationId);
if (!licensed) {
return new Set(allOrgServers.map((s) => s.serverId));
}
return new Set(memberRecord?.accessedServers ?? []);
};