mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-19 06:05:25 +02:00
- Introduced new test files for permission checks, including `check-permission.test.ts`, `enterprise-only-resources.test.ts`, `resolve-permissions.test.ts`, and `service-access.test.ts`. - Implemented permission checks in various components to ensure actions are gated by user permissions, including `ShowTraefikConfig`, `UpdateTraefikConfig`, `ShowVolumes`, `ShowDomains`, and others. - Enhanced the logic for displaying UI elements based on user permissions, ensuring that only authorized users can access or modify resources.
563 lines
14 KiB
TypeScript
563 lines
14 KiB
TypeScript
import type { IncomingMessage } from "node:http";
|
|
import { apiKey } from "@better-auth/api-key";
|
|
import { sso } from "@better-auth/sso";
|
|
import * as bcrypt from "bcrypt";
|
|
import { betterAuth } from "better-auth";
|
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
import { APIError } from "better-auth/api";
|
|
import { admin, organization, twoFactor } from "better-auth/plugins";
|
|
import { and, desc, eq } from "drizzle-orm";
|
|
import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants";
|
|
import { db } from "../db";
|
|
import * as schema from "../db/schema";
|
|
import {
|
|
getTrustedOrigins,
|
|
getTrustedProviders,
|
|
getUserByToken,
|
|
} from "../services/admin";
|
|
import { createAuditLog } from "../services/proprietary/audit-log";
|
|
import {
|
|
getWebServerSettings,
|
|
updateWebServerSettings,
|
|
} from "../services/web-server-settings";
|
|
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
|
import { sendEmail } from "../verification/send-verification-email";
|
|
import { getPublicIpWithFallback } from "../wss/utils";
|
|
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
|
|
|
const { handler, api } = betterAuth({
|
|
database: drizzleAdapter(db, {
|
|
provider: "pg",
|
|
schema: schema,
|
|
}),
|
|
disabledPaths: [
|
|
"/sso/register",
|
|
"/organization/create",
|
|
"/organization/update",
|
|
"/organization/delete",
|
|
],
|
|
secret: BETTER_AUTH_SECRET,
|
|
...(!IS_CLOUD
|
|
? {
|
|
advanced: {
|
|
useSecureCookies: false,
|
|
defaultCookieAttributes: {
|
|
sameSite: "lax",
|
|
secure: false,
|
|
httpOnly: true,
|
|
path: "/",
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
|
|
account: {
|
|
accountLinking: {
|
|
enabled: true,
|
|
async trustedProviders() {
|
|
const fromDb = await getTrustedProviders();
|
|
return ["github", "google", ...fromDb];
|
|
},
|
|
allowDifferentEmails: true,
|
|
},
|
|
},
|
|
appName: "Dokploy",
|
|
socialProviders: {
|
|
github: {
|
|
clientId: process.env.GITHUB_CLIENT_ID as string,
|
|
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
|
},
|
|
google: {
|
|
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
|
},
|
|
},
|
|
logger: {
|
|
disabled: process.env.NODE_ENV === "production",
|
|
},
|
|
async trustedOrigins() {
|
|
if (IS_CLOUD) {
|
|
return getTrustedOrigins();
|
|
}
|
|
const [trustedOrigins, settings] = await Promise.all([
|
|
getTrustedOrigins(),
|
|
getWebServerSettings(),
|
|
]);
|
|
if (!settings) return [];
|
|
const devOrigins =
|
|
process.env.NODE_ENV === "development"
|
|
? [
|
|
"http://localhost:3000",
|
|
"https://absolutely-handy-falcon.ngrok-free.app",
|
|
]
|
|
: [];
|
|
return [
|
|
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
|
...(settings?.host ? [`https://${settings?.host}`] : []),
|
|
...devOrigins,
|
|
...trustedOrigins,
|
|
];
|
|
},
|
|
emailVerification: {
|
|
sendOnSignUp: true,
|
|
autoSignInAfterVerification: true,
|
|
sendVerificationEmail: async ({ user, url }) => {
|
|
if (IS_CLOUD) {
|
|
await sendEmail({
|
|
email: user.email,
|
|
subject: "Verify your email",
|
|
text: `
|
|
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
|
|
`,
|
|
});
|
|
}
|
|
},
|
|
},
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
autoSignIn: !IS_CLOUD,
|
|
requireEmailVerification: IS_CLOUD,
|
|
password: {
|
|
async hash(password) {
|
|
return bcrypt.hashSync(password, 10);
|
|
},
|
|
async verify({ hash, password }) {
|
|
return bcrypt.compareSync(password, hash);
|
|
},
|
|
},
|
|
sendResetPassword: async ({ user, url }) => {
|
|
await sendEmail({
|
|
email: user.email,
|
|
subject: "Reset your password",
|
|
text: `
|
|
<p>Click the link to reset your password: <a href="${url}">Reset Password</a></p>
|
|
`,
|
|
});
|
|
},
|
|
},
|
|
databaseHooks: {
|
|
user: {
|
|
create: {
|
|
before: async (_user, context) => {
|
|
if (!IS_CLOUD) {
|
|
const xDokployToken =
|
|
context?.request?.headers?.get("x-dokploy-token");
|
|
if (xDokployToken) {
|
|
const user = await getUserByToken(xDokployToken);
|
|
if (!user) {
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: "User not found",
|
|
});
|
|
}
|
|
} else {
|
|
const isSSORequest = context?.path.includes("/sso");
|
|
if (isSSORequest) {
|
|
return;
|
|
}
|
|
const isAdminPresent = await db.query.member.findFirst({
|
|
where: eq(schema.member.role, "owner"),
|
|
});
|
|
if (isAdminPresent) {
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: "Admin is already created",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
after: async (user, context) => {
|
|
const isSSORequest = context?.path.includes("/sso");
|
|
const isAdminPresent = await db.query.member.findFirst({
|
|
where: eq(schema.member.role, "owner"),
|
|
});
|
|
|
|
if (!IS_CLOUD) {
|
|
await updateWebServerSettings({
|
|
serverIp: await getPublicIpWithFallback(),
|
|
});
|
|
}
|
|
|
|
if (IS_CLOUD) {
|
|
try {
|
|
const hutk = getHubSpotUTK(
|
|
context?.request?.headers?.get("cookie") || undefined,
|
|
);
|
|
// Cast to include additional fields
|
|
const userWithFields = user as typeof user & {
|
|
lastName?: string;
|
|
};
|
|
const hubspotSuccess = await submitToHubSpot(
|
|
{
|
|
email: user.email,
|
|
firstName: user.name || "", // name is mapped to firstName column
|
|
lastName: userWithFields.lastName || "",
|
|
},
|
|
hutk,
|
|
);
|
|
if (!hubspotSuccess) {
|
|
console.error("Failed to submit to HubSpot");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error submitting to HubSpot", error);
|
|
}
|
|
}
|
|
|
|
if (IS_CLOUD || !isAdminPresent) {
|
|
await db.transaction(async (tx) => {
|
|
const organization = await tx
|
|
.insert(schema.organization)
|
|
.values({
|
|
name: "My Organization",
|
|
ownerId: user.id,
|
|
createdAt: new Date(),
|
|
})
|
|
.returning()
|
|
.then((res) => res[0]);
|
|
|
|
await tx.insert(schema.member).values({
|
|
userId: user.id,
|
|
organizationId: organization?.id || "",
|
|
role: "owner",
|
|
createdAt: new Date(),
|
|
isDefault: true, // Mark first organization as default
|
|
});
|
|
});
|
|
} else if (isSSORequest) {
|
|
const providerId = context?.params?.providerId;
|
|
if (!providerId) {
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: "Provider ID is required",
|
|
});
|
|
}
|
|
const provider = await db.query.ssoProvider.findFirst({
|
|
where: eq(schema.ssoProvider.providerId, providerId),
|
|
});
|
|
|
|
if (!provider) {
|
|
throw new APIError("BAD_REQUEST", {
|
|
message: "Provider not found",
|
|
});
|
|
}
|
|
await db.insert(schema.member).values({
|
|
userId: user.id,
|
|
organizationId: provider?.organizationId || "",
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
isDefault: true,
|
|
});
|
|
}
|
|
},
|
|
},
|
|
},
|
|
session: {
|
|
create: {
|
|
before: async (session) => {
|
|
// Find the default organization for this user
|
|
// Priority: 1) isDefault=true, 2) most recently created
|
|
const member = await db.query.member.findFirst({
|
|
where: eq(schema.member.userId, session.userId),
|
|
orderBy: [
|
|
desc(schema.member.isDefault),
|
|
desc(schema.member.createdAt),
|
|
],
|
|
with: {
|
|
organization: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
data: {
|
|
...session,
|
|
activeOrganizationId: member?.organization.id,
|
|
},
|
|
};
|
|
},
|
|
after: async (session) => {
|
|
const orgId = (
|
|
session as typeof session & { activeOrganizationId?: string }
|
|
).activeOrganizationId;
|
|
if (!orgId) return;
|
|
const memberRecord = await db.query.member.findFirst({
|
|
where: and(
|
|
eq(schema.member.userId, session.userId),
|
|
eq(schema.member.organizationId, orgId),
|
|
),
|
|
with: { user: true },
|
|
});
|
|
if (!memberRecord) return;
|
|
await createAuditLog({
|
|
organizationId: orgId,
|
|
userId: session.userId,
|
|
userEmail: memberRecord.user.email,
|
|
userRole: memberRecord.role,
|
|
action: "login",
|
|
resourceType: "session",
|
|
});
|
|
},
|
|
},
|
|
delete: {
|
|
after: async (session) => {
|
|
const orgId = (
|
|
session as typeof session & { activeOrganizationId?: string }
|
|
).activeOrganizationId;
|
|
if (!orgId) return;
|
|
const memberRecord = await db.query.member.findFirst({
|
|
where: and(
|
|
eq(schema.member.userId, session.userId),
|
|
eq(schema.member.organizationId, orgId),
|
|
),
|
|
with: { user: true },
|
|
});
|
|
if (!memberRecord) return;
|
|
await createAuditLog({
|
|
organizationId: orgId,
|
|
userId: session.userId,
|
|
userEmail: memberRecord.user.email,
|
|
userRole: memberRecord.role,
|
|
action: "logout",
|
|
resourceType: "session",
|
|
});
|
|
},
|
|
},
|
|
},
|
|
},
|
|
session: {
|
|
expiresIn: 60 * 60 * 24 * 3,
|
|
updateAge: 60 * 60 * 24,
|
|
},
|
|
user: {
|
|
modelName: "user",
|
|
fields: {
|
|
name: "firstName", // Map better-auth's default 'name' field to 'firstName' column
|
|
},
|
|
additionalFields: {
|
|
role: {
|
|
type: "string",
|
|
// required: true,
|
|
input: false,
|
|
},
|
|
ownerId: {
|
|
type: "string",
|
|
// required: true,
|
|
input: false,
|
|
},
|
|
allowImpersonation: {
|
|
fieldName: "allowImpersonation",
|
|
type: "boolean",
|
|
defaultValue: false,
|
|
},
|
|
lastName: {
|
|
type: "string",
|
|
required: false,
|
|
input: true,
|
|
defaultValue: "",
|
|
},
|
|
enableEnterpriseFeatures: {
|
|
type: "boolean",
|
|
required: false,
|
|
input: false,
|
|
},
|
|
isValidEnterpriseLicense: {
|
|
type: "boolean",
|
|
required: false,
|
|
input: false,
|
|
},
|
|
},
|
|
},
|
|
plugins: [
|
|
apiKey({
|
|
enableMetadata: true,
|
|
}),
|
|
sso(),
|
|
twoFactor(),
|
|
organization({
|
|
ac,
|
|
roles: {
|
|
owner: ownerRole,
|
|
admin: adminRole,
|
|
member: memberRole,
|
|
},
|
|
dynamicAccessControl: {
|
|
enabled: true,
|
|
maximumRolesPerOrganization: 10,
|
|
},
|
|
async sendInvitationEmail(data, _request) {
|
|
if (IS_CLOUD) {
|
|
const host =
|
|
process.env.NODE_ENV === "development"
|
|
? "http://localhost:3000"
|
|
: "https://app.dokploy.com";
|
|
const inviteLink = `${host}/invitation?token=${data.id}`;
|
|
|
|
await sendEmail({
|
|
email: data.email,
|
|
subject: "Invitation to join organization",
|
|
text: `
|
|
<p>You are invited to join ${data.organization.name} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
|
|
`,
|
|
});
|
|
}
|
|
},
|
|
}),
|
|
...(IS_CLOUD
|
|
? [
|
|
admin({
|
|
adminUserIds: [process.env.USER_ADMIN_ID as string],
|
|
}),
|
|
]
|
|
: []),
|
|
],
|
|
});
|
|
|
|
const _auth = {
|
|
handler,
|
|
createApiKey: api.createApiKey,
|
|
registerSSOProvider: api.registerSSOProvider,
|
|
updateSSOProvider: api.updateSSOProvider,
|
|
};
|
|
|
|
export type AuthType = typeof _auth;
|
|
export const auth: AuthType = _auth;
|
|
|
|
export const validateRequest = async (request: IncomingMessage) => {
|
|
const apiKey = request.headers["x-api-key"] as string;
|
|
if (apiKey) {
|
|
try {
|
|
const { valid, key, error } = await api.verifyApiKey({
|
|
body: {
|
|
key: apiKey,
|
|
},
|
|
});
|
|
|
|
if (error) {
|
|
throw new Error(error.message?.toString() || "Error verifying API key");
|
|
}
|
|
if (!valid || !key) {
|
|
return {
|
|
session: null,
|
|
user: null,
|
|
};
|
|
}
|
|
|
|
const apiKeyRecord = await db.query.apikey.findFirst({
|
|
where: eq(schema.apikey.id, key.id),
|
|
with: {
|
|
user: true,
|
|
},
|
|
});
|
|
|
|
if (!apiKeyRecord) {
|
|
return {
|
|
session: null,
|
|
user: null,
|
|
};
|
|
}
|
|
|
|
const organizationId = JSON.parse(
|
|
apiKeyRecord.metadata || "{}",
|
|
).organizationId;
|
|
|
|
if (!organizationId) {
|
|
return {
|
|
session: null,
|
|
user: null,
|
|
};
|
|
}
|
|
|
|
const member = await db.query.member.findFirst({
|
|
where: and(
|
|
eq(schema.member.userId, apiKeyRecord.user.id),
|
|
eq(schema.member.organizationId, organizationId),
|
|
),
|
|
with: {
|
|
organization: true,
|
|
},
|
|
});
|
|
|
|
// When accessing from DB, use actual column names
|
|
const userFromDb = apiKeyRecord.user as typeof apiKeyRecord.user & {
|
|
firstName: string;
|
|
lastName: string;
|
|
};
|
|
|
|
const mockSession = {
|
|
session: {
|
|
userId: apiKeyRecord.user.id,
|
|
activeOrganizationId: organizationId || "",
|
|
},
|
|
user: {
|
|
id: userFromDb.id,
|
|
name: userFromDb.firstName, // Map firstName back to name for better-auth
|
|
email: userFromDb.email,
|
|
emailVerified: userFromDb.emailVerified,
|
|
image: userFromDb.image,
|
|
createdAt: userFromDb.createdAt,
|
|
updatedAt: userFromDb.updatedAt,
|
|
twoFactorEnabled: userFromDb.twoFactorEnabled,
|
|
role: member?.role || "member",
|
|
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
|
|
enableEnterpriseFeatures: userFromDb.enableEnterpriseFeatures,
|
|
isValidEnterpriseLicense: userFromDb.isValidEnterpriseLicense,
|
|
},
|
|
};
|
|
|
|
return mockSession;
|
|
} catch (error) {
|
|
console.error("Error verifying API key", error);
|
|
return {
|
|
session: null,
|
|
user: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
// If no API key, proceed with normal session validation
|
|
const session = await api.getSession({
|
|
headers: new Headers({
|
|
cookie: request.headers.cookie || "",
|
|
}),
|
|
});
|
|
|
|
if (!session?.session || !session.user) {
|
|
return {
|
|
session: null,
|
|
user: null,
|
|
};
|
|
}
|
|
|
|
if (session?.user) {
|
|
const member = await db.query.member.findFirst({
|
|
where: and(
|
|
eq(schema.member.userId, session.user.id),
|
|
...(session.session.activeOrganizationId
|
|
? [
|
|
eq(
|
|
schema.member.organizationId,
|
|
session.session.activeOrganizationId || "",
|
|
),
|
|
]
|
|
: []),
|
|
),
|
|
orderBy: [desc(schema.member.isDefault), desc(schema.member.createdAt)],
|
|
with: {
|
|
organization: true,
|
|
user: true,
|
|
},
|
|
});
|
|
|
|
session.user.role = member?.role || "member";
|
|
session.user.enableEnterpriseFeatures =
|
|
member?.user.enableEnterpriseFeatures || false;
|
|
session.user.isValidEnterpriseLicense =
|
|
member?.user.isValidEnterpriseLicense || false;
|
|
session.session.activeOrganizationId = member?.organization.id || "";
|
|
if (member) {
|
|
session.user.ownerId = member.organization.ownerId;
|
|
} else {
|
|
session.user.ownerId = session.user.id;
|
|
}
|
|
}
|
|
|
|
return session;
|
|
};
|