Merge branch 'canary' into feat/quick-service-switcher

This commit is contained in:
Mauricio Siu
2026-03-19 00:43:03 -06:00
169 changed files with 91767 additions and 28607 deletions

View File

@@ -0,0 +1,431 @@
import { db } from "@dokploy/server/db";
import { member, organizationRole } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import {
ac,
adminRole,
enterpriseOnlyResources,
memberRole,
ownerRole,
statements,
} from "../lib/access-control";
type Statements = typeof statements;
type Resource = keyof Statements;
type Action<R extends Resource> = Statements[R][number];
type Permissions = {
[R in Resource]?: Action<R>[];
};
export type PermissionCtx = {
user: { id: string };
session: { activeOrganizationId: string };
};
export type ResolvedPermissions = {
[R in Resource]: {
[A in Statements[R][number]]: boolean;
};
};
const staticRoles: Record<string, ReturnType<typeof ac.newRole>> = {
owner: ownerRole,
admin: adminRole,
member: memberRole,
};
const resolveRole = async (
roleName: string,
organizationId: string,
): Promise<ReturnType<typeof ac.newRole> | null> => {
if (staticRoles[roleName]) {
return staticRoles[roleName];
}
const licensed = await hasValidLicense(organizationId);
if (!licensed) {
return null;
}
const customRoles = await db.query.organizationRole.findMany({
where: and(
eq(organizationRole.organizationId, organizationId),
eq(organizationRole.role, roleName),
),
});
if (customRoles.length === 0) {
return null;
}
const merged: Record<string, string[]> = {};
for (const entry of customRoles) {
const parsed = JSON.parse(entry.permission) as Record<string, string[]>;
for (const [resource, actions] of Object.entries(parsed)) {
merged[resource] = [
...new Set([...(merged[resource] ?? []), ...actions]),
];
}
}
return ac.newRole(merged as any);
};
export const checkPermission = async (
ctx: PermissionCtx,
permissions: Permissions,
) => {
const { id: userId } = ctx.user;
const { activeOrganizationId: organizationId } = ctx.session;
const memberRecord = await findMemberByUserId(userId, organizationId);
const isStaticRole = memberRecord.role in staticRoles;
if (isStaticRole) {
const allEnterprise = Object.keys(permissions).every((r) =>
enterpriseOnlyResources.has(r),
);
if (allEnterprise) return;
}
const role = await resolveRole(memberRecord.role, organizationId);
if (!role) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid role",
});
}
const result = role.authorize(permissions);
if (result.success) {
return;
}
if (memberRecord.role === "member") {
const overrides = getLegacyOverrides(memberRecord);
const allGranted = Object.entries(permissions).every(
([resource, actions]) =>
(actions as string[]).every(
(action) =>
!!(overrides[resource] as Record<string, boolean> | undefined)?.[
action
],
),
);
if (allGranted) {
return;
}
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: result.error || "Permission denied",
});
};
export const hasPermission = async (
ctx: PermissionCtx,
permissions: Permissions,
): Promise<boolean> => {
try {
await checkPermission(ctx, permissions);
return true;
} catch {
return false;
}
};
const getLegacyOverrides = (
memberRecord: Awaited<ReturnType<typeof findMemberByUserId>>,
): Partial<Record<string, Record<string, boolean>>> => {
return {
project: {
create: !!memberRecord.canCreateProjects,
delete: !!memberRecord.canDeleteProjects,
},
service: {
create: !!memberRecord.canCreateServices,
delete: !!memberRecord.canDeleteServices,
},
environment: {
create: !!memberRecord.canCreateEnvironments,
delete: !!memberRecord.canDeleteEnvironments,
},
traefikFiles: {
read: !!memberRecord.canAccessToTraefikFiles,
},
docker: {
read: !!memberRecord.canAccessToDocker,
},
api: {
read: !!memberRecord.canAccessToAPI,
},
sshKeys: {
read: !!memberRecord.canAccessToSSHKeys,
},
gitProviders: {
read: !!memberRecord.canAccessToGitProviders,
},
};
};
export const resolvePermissions = async (
ctx: PermissionCtx,
): Promise<ResolvedPermissions> => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
const role = await resolveRole(memberRecord.role, organizationId);
const legacyOverrides =
memberRecord.role === "member" ? getLegacyOverrides(memberRecord) : {};
const isPrivilegedRole =
memberRecord.role === "owner" || memberRecord.role === "admin";
const result = {} as ResolvedPermissions;
for (const [resource, actions] of Object.entries(statements)) {
const resourcePerms = {} as Record<string, boolean>;
for (const action of actions) {
if (isPrivilegedRole && enterpriseOnlyResources.has(resource)) {
resourcePerms[action] = true;
continue;
}
if (!role) {
resourcePerms[action] = false;
continue;
}
const check = role.authorize({ [resource]: [action] });
resourcePerms[action] =
check.success ||
!!(legacyOverrides[resource] as Record<string, boolean> | undefined)?.[
action
];
}
(result as any)[resource] = resourcePerms;
}
return result;
};
export const checkProjectAccess = async (
ctx: PermissionCtx,
action: "create" | "delete",
projectId?: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { project: [action] });
if (
action !== "create" &&
projectId &&
memberRecord.role !== "owner" &&
memberRecord.role !== "admin"
) {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const checkServicePermissionAndAccess = async (
ctx: PermissionCtx,
serviceId: string,
permissions: Permissions,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, permissions);
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedServices.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this service",
});
}
}
};
export const checkServiceAccess = async (
ctx: PermissionCtx,
serviceId: string,
action: "create" | "read" | "delete" = "read",
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { service: [action] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (action === "create") {
if (!memberRecord.accessedProjects.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
} else {
if (!memberRecord.accessedServices.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this service",
});
}
}
}
};
export const checkEnvironmentAccess = async (
ctx: PermissionCtx,
environmentId: string,
action: "read" | "create" | "delete" = "read",
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: [action] });
if (
action !== "create" &&
memberRecord.role !== "owner" &&
memberRecord.role !== "admin"
) {
if (!memberRecord.accessedEnvironments.includes(environmentId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this environment",
});
}
}
};
export const checkEnvironmentCreationPermission = async (
ctx: PermissionCtx,
projectId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: ["create"] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const checkEnvironmentDeletionPermission = async (
ctx: PermissionCtx,
projectId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: ["delete"] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const addNewProject = async (ctx: PermissionCtx, projectId: string) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedProjects: [...memberRecord.accessedProjects, projectId],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const addNewEnvironment = async (
ctx: PermissionCtx,
environmentId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedEnvironments: [
...memberRecord.accessedEnvironments,
environmentId,
],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const addNewService = async (ctx: PermissionCtx, serviceId: string) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedServices: [...memberRecord.accessedServices, serviceId],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const findMemberByUserId = async (
userId: string,
organizationId: string,
) => {
const result = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, organizationId),
),
with: {
user: true,
},
});
if (!result) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
}
return result;
};

View File

@@ -0,0 +1,95 @@
import { db } from "@dokploy/server/db";
import { auditLog } from "@dokploy/server/db/schema";
import type { AuditAction, AuditResourceType } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { and, desc, eq, gte, ilike, lte } from "drizzle-orm";
export type { AuditAction, AuditResourceType };
export interface CreateAuditLogInput {
organizationId: string;
userId: string;
userEmail: string;
userRole: string;
action: AuditAction;
resourceType: AuditResourceType;
resourceId?: string;
resourceName?: string;
metadata?: Record<string, unknown>;
}
/**
* Creates an audit log entry. Fire-and-forget safe — errors are swallowed
* so a logging failure never breaks the main operation.
*/
export const createAuditLog = async (input: CreateAuditLogInput) => {
try {
const licensed = await hasValidLicense(input.organizationId);
if (!licensed) return;
await db.insert(auditLog).values({
organizationId: input.organizationId,
userId: input.userId,
userEmail: input.userEmail,
userRole: input.userRole,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId,
resourceName: input.resourceName,
metadata: input.metadata ? JSON.stringify(input.metadata) : undefined,
});
} catch (err) {
console.error("[audit-log] Failed to create audit log entry:", err);
}
};
export interface GetAuditLogsInput {
organizationId: string;
userId?: string;
userEmail?: string;
resourceName?: string;
action?: AuditAction;
resourceType?: AuditResourceType;
from?: Date;
to?: Date;
limit?: number;
offset?: number;
}
export const getAuditLogs = async (input: GetAuditLogsInput) => {
const {
organizationId,
userId,
userEmail,
resourceName,
action,
resourceType,
from,
to,
limit = 50,
offset = 0,
} = input;
const conditions = [eq(auditLog.organizationId, organizationId)];
if (userId) conditions.push(eq(auditLog.userId, userId));
if (userEmail) conditions.push(ilike(auditLog.userEmail, `%${userEmail}%`));
if (resourceName)
conditions.push(ilike(auditLog.resourceName, `%${resourceName}%`));
if (action) conditions.push(eq(auditLog.action, action));
if (resourceType) conditions.push(eq(auditLog.resourceType, resourceType));
if (from) conditions.push(gte(auditLog.createdAt, from));
if (to) conditions.push(lte(auditLog.createdAt, to));
const [logs, total] = await Promise.all([
db.query.auditLog.findMany({
where: and(...conditions),
orderBy: [desc(auditLog.createdAt)],
limit,
offset,
}),
db.$count(auditLog, and(...conditions)),
]);
return { logs, total };
};

View File

@@ -432,7 +432,7 @@ export const createApiKey = async (
refillInterval?: number;
},
) => {
const apiKey = await auth.createApiKey({
const result = await auth.createApiKey({
body: {
name: input.name,
expiresIn: input.expiresIn,
@@ -450,10 +450,9 @@ export const createApiKey = async (
if (input.metadata) {
await db
.update(apikey)
.set({
metadata: JSON.stringify(input.metadata),
})
.where(eq(apikey.id, apiKey.id));
.set({ metadata: JSON.stringify(input.metadata) })
.where(eq(apikey.id, result.id));
}
return apiKey;
return result;
};