Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 105 additions & 40 deletions apps/dokploy/server/api/routers/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IS_CLOUD, removeScheduleJob, scheduleJob } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { member } from "@dokploy/server/db/schema";
import { deployments } from "@dokploy/server/db/schema/deployment";
import {
createScheduleSchema,
Expand All @@ -20,7 +21,7 @@ import {
} from "@dokploy/server/services/schedule";
import { findServerById } from "@dokploy/server/services/server";
import { TRPCError } from "@trpc/server";
import { asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { removeJob, schedule } from "@/server/utils/backup";
Expand Down Expand Up @@ -163,16 +164,26 @@ export const scheduleRouter = createTRPCRouter({
}
}

if (
existingSchedule.scheduleType === "dokploy-server" &&
existingSchedule.userId &&
existingSchedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
if (
existingSchedule.scheduleType === "dokploy-server" &&
existingSchedule.userId &&
existingSchedule.userId !== ctx.user.id
) {
// Admin/owner role already verified above.
// Don't use findMemberByUserId here — it would throw if the
// schedule owner left the org, making the schedule unmanageable.
const scheduleOwnerInOrg = await db.query.member.findFirst({
where: and(
eq(member.userId, existingSchedule.userId),
eq(member.organizationId, ctx.session.activeOrganizationId),
),
columns: { id: true },
});
if (scheduleOwnerInOrg) {
// Owner is still in org — allowed.
}
// If owner left the org, still allow the admin/owner to manage it.
}
}
Comment on lines +167 to 187

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Authorization check is dead code — org boundary is never enforced

The scheduleOwnerInOrg lookup is performed but its result is never used to restrict access: the if (scheduleOwnerInOrg) { /* allowed */ } body is empty and there is no else { throw } branch. Execution always falls through to updateSchedule(input) regardless of whether the schedule owner is in the org or not. This means the only guard remaining is the admin/owner role check — org-scoping for dokploy-server schedules has been fully removed.

In a multi-org self-hosted deployment an admin from org A can update, delete, view (one), and run any dokploy-server schedule created by a user in org B by knowing its scheduleId. The same dead-code pattern repeats in delete (lines 271-288), one (lines 436-445), and runManually (lines 500-517).

If cross-org access is intentionally accepted, remove the dead lookup entirely (it wastes a DB round-trip). If within-org + orphaned-schedule access is the intended behaviour, add an explicit throw when the owner is found to belong to a different org's member record. As written, the DB query is misleading: it implies a check is being made while actually allowing everything.

const updatedSchedule = await updateSchedule(input);

Expand Down Expand Up @@ -262,10 +273,18 @@ export const scheduleRouter = createTRPCRouter({
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
// Admin/owner role already verified above.
// Don't throw if the schedule owner left the org.
const scheduleOwnerInOrg = await db.query.member.findFirst({
where: and(
eq(member.userId, scheduleItem.userId),
eq(member.organizationId, ctx.session.activeOrganizationId),
),
columns: { id: true },
});
if (scheduleOwnerInOrg) {
// Owner is still in org — allowed.
}
}
}
await deleteSchedule(input.scheduleId);
Expand Down Expand Up @@ -322,36 +341,54 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
}

if (
input.scheduleType === "dokploy-server" &&
input.id !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only list your own host-level schedules.",
});
let listWhere;
if (input.scheduleType === "dokploy-server") {
const currentMember = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (
currentMember.role === "owner" ||
currentMember.role === "admin"
) {
const orgMembers = await db.query.member.findMany({
where: eq(member.organizationId, ctx.session.activeOrganizationId),
columns: { userId: true },
});
const userIds = orgMembers.map((m) => m.userId);
if (userIds.length === 0) {
return [];
}
listWhere = and(
eq(schedules.scheduleType, "dokploy-server"),
inArray(schedules.userId, userIds),
);
Comment on lines +364 to +367

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 inArray with a potentially empty array

If orgMembers is somehow empty, inArray(schedules.userId, []) generates IN () which is invalid SQL and will throw a database error. In practice orgMembers always contains at least the calling admin, but adding an early-return guard (if (userIds.length === 0) return []) would make this robust and self-documenting.

} else {
listWhere = eq(schedules.userId, ctx.user.id);
}
} else {
const where = {
application: eq(schedules.applicationId, input.id),
compose: eq(schedules.composeId, input.id),
server: eq(schedules.serverId, input.id),
"dokploy-server": eq(schedules.userId, input.id),
};
return db.query.schedules.findMany({
where: where[input.scheduleType],
orderBy: [asc(schedules.createdAt)],
with: {
application: true,
server: true,
compose: true,
deployments: {
orderBy: [desc(deployments.createdAt)],
},
listWhere = where[input.scheduleType];
}
return db.query.schedules.findMany({
where: listWhere,
orderBy: [asc(schedules.createdAt)],
with: {
application: true,
server: true,
compose: true,
deployments: {
orderBy: [desc(deployments.createdAt)],
},
});
}),
},
});
}),

one: protectedProcedure
.input(z.object({ scheduleId: z.string() }))
Expand Down Expand Up @@ -382,10 +419,30 @@ export const scheduleRouter = createTRPCRouter({
schedule.userId &&
schedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
const currentMember = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (
currentMember.role !== "owner" &&
currentMember.role !== "admin"
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
});
}
// Don't throw if the schedule owner left the org.
const scheduleOwnerInOrg = await db.query.member.findFirst({
where: and(
eq(member.userId, schedule.userId),
eq(member.organizationId, ctx.session.activeOrganizationId),
),
columns: { id: true },
});
if (scheduleOwnerInOrg) {
// Owner is still in org — allowed.
}
}
}
return schedule;
Expand Down Expand Up @@ -445,10 +502,18 @@ export const scheduleRouter = createTRPCRouter({
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
// Admin/owner role already verified above.
// Don't throw if the schedule owner left the org.
const scheduleOwnerInOrg = await db.query.member.findFirst({
where: and(
eq(member.userId, scheduleItem.userId),
eq(member.organizationId, ctx.session.activeOrganizationId),
),
columns: { id: true },
});
if (scheduleOwnerInOrg) {
// Owner is still in org — allowed.
}
}
}
try {
Expand Down