feat(preview): add collaborator permission requirement for preview deployments

- Introduced a new boolean field `previewRequireCollaboratorPermissions` in the application schema to enforce permission checks for preview deployments.
- Updated the UI to include a toggle for this setting in the preview deployment settings.
- Enhanced GitHub deployment handler to validate PR authors against the required permissions, blocking unauthorized deployments and providing security notifications.
- Added SQL migration to update the database schema accordingly.
This commit is contained in:
Mauricio Siu
2025-07-13 23:12:09 -06:00
parent 3f3ff9670b
commit 2ca5321fdc
9 changed files with 6454 additions and 2 deletions

View File

@@ -10,6 +10,9 @@ import {
findPreviewDeploymentsByPullRequestId,
removePreviewDeployment,
shouldDeploy,
findGithubById,
checkUserRepositoryPermissions,
createSecurityBlockedComment,
} from "@dokploy/server";
import { Webhooks } from "@octokit/webhooks";
import { and, eq } from "drizzle-orm";
@@ -346,6 +349,18 @@ export default async function handler(
const deploymentHash = githubBody?.pull_request?.head?.sha;
const branch = githubBody?.pull_request?.base?.ref;
const owner = githubBody?.repository?.owner?.login;
const prAuthor = githubBody?.pull_request?.user?.login;
// Validate PR author information is present
if (!prAuthor) {
console.warn(
"⚠️ SECURITY: PR author information missing in webhook payload",
);
res.status(400).json({
message: "PR author information missing",
});
return;
}
const apps = await db.query.applications.findMany({
where: and(
@@ -361,15 +376,74 @@ export default async function handler(
},
});
// SECURITY: Check collaborator permissions per application setting
const secureApps: typeof apps = [];
const blockedApps: string[] = [];
let userPermission: string | null = null;
for (const app of apps) {
// If the app requires collaborator permissions, verify them
if (app.previewRequireCollaboratorPermissions !== false) {
try {
const githubProvider = await findGithubById(githubResult.githubId);
const { hasWriteAccess, permission } =
await checkUserRepositoryPermissions(
githubProvider,
owner,
repository,
prAuthor,
);
userPermission = permission; // Store permission for comment
if (!hasWriteAccess) {
console.warn(
`🚨 SECURITY: Blocked preview deployment for ${app.name} from unauthorized user ${prAuthor} on ${owner}/${repository}. Permission: ${permission || "none"}`,
);
blockedApps.push(app.name);
continue;
}
console.log(
`✅ SECURITY: Preview deployment authorized for ${app.name} from user ${prAuthor} on ${owner}/${repository}. Permission: ${permission}`,
);
} catch (error) {
console.error(
`Error validating PR author permissions for ${app.name}:`,
error,
);
blockedApps.push(app.name);
continue; // Skip this app on error
}
} else {
console.warn(
`⚠️ SECURITY: Preview deployment for ${app.name} allows deployment from any PR author (security check disabled)`,
);
}
secureApps.push(app);
}
const prBranch = githubBody?.pull_request?.head?.ref;
const prNumber = githubBody?.pull_request?.number;
const prTitle = githubBody?.pull_request?.title;
const prURL = githubBody?.pull_request?.html_url;
for (const app of apps) {
// Create security notification comment if any apps were blocked
if (blockedApps.length > 0) {
await createSecurityBlockedComment({
owner,
repository,
prNumber: Number.parseInt(prNumber),
prAuthor,
permission: userPermission,
githubId: githubResult.githubId,
});
}
for (const app of secureApps) {
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
if (app?.previewDeployments?.length >= previewLimit) {
continue;
}
const previewDeploymentResult =