mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-23 08:55:25 +00:00
Merge pull request #5489 from imrja8/feat/ai-custom-role-permissions
feat(permissions): add AI module to enterprise custom roles
This commit is contained in:
@@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
"ai",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||
});
|
||||
expect(bytes).toBe(11);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||
@@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||
}),
|
||||
).rejects.toThrow("target exited with code 3: boom");
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("reports a source failure", async () => {
|
||||
await expect(
|
||||
@@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "cat > /dev/null" },
|
||||
}),
|
||||
).rejects.toThrow("source exited with code 2");
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers";
|
||||
export const AiForm = () => {
|
||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
|
||||
const isOrgAdmin =
|
||||
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||
|
||||
@@ -36,7 +38,9 @@ export const AiForm = () => {
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
{isOrgAdmin && <HandleAiProviders />}
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
|
||||
<HandleAi />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -53,7 +57,7 @@ export const AiForm = () => {
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any AI configurations
|
||||
</span>
|
||||
<HandleAi />
|
||||
{permissions?.ai.create && <HandleAi />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
||||
@@ -70,33 +74,37 @@ export const AiForm = () => {
|
||||
<CardDescription>{config.model}</CardDescription>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<HandleAi aiId={config.aiId} />
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
{permissions?.ai.update && (
|
||||
<HandleAi aiId={config.aiId} />
|
||||
)}
|
||||
{permissions?.ai.delete && (
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
{
|
||||
apiUrl: apiUrl ?? "",
|
||||
apiKey: apiKey ?? "",
|
||||
...(aiId ? { aiId } : {}),
|
||||
},
|
||||
{
|
||||
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||
@@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
apiUrl={apiUrl}
|
||||
apiKey={apiKey}
|
||||
model={form.watch("model")}
|
||||
aiId={aiId}
|
||||
/>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{aiId ? "Update" : "Create"}
|
||||
@@ -498,10 +500,12 @@ function TestConnectionButton({
|
||||
apiUrl,
|
||||
apiKey,
|
||||
model,
|
||||
aiId,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
aiId?: string;
|
||||
}) {
|
||||
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -521,7 +525,9 @@ function TestConnectionButton({
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDisabled || isPending}
|
||||
onClick={() => mutate({ apiUrl, apiKey, model })}
|
||||
onClick={() =>
|
||||
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -351,7 +351,13 @@ const MENU: Menu = {
|
||||
icon: BotIcon,
|
||||
url: "/dashboard/settings/ai",
|
||||
isSingle: true,
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
isEnabled: ({ permissions }) =>
|
||||
!!(
|
||||
permissions?.ai.read ||
|
||||
permissions?.ai.create ||
|
||||
permissions?.ai.update ||
|
||||
permissions?.ai.delete
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
|
||||
@@ -185,6 +185,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
description:
|
||||
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
|
||||
},
|
||||
ai: {
|
||||
label: "AI Settings",
|
||||
description:
|
||||
"Manage AI providers, models, and use AI features (log analysis, suggestions)",
|
||||
},
|
||||
};
|
||||
|
||||
/** Descriptions for each action within a resource */
|
||||
@@ -495,6 +500,25 @@ const ACTION_META: Record<
|
||||
description: "Remove logs and metrics providers",
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description:
|
||||
"View AI providers and use AI features (log analysis, suggestions)",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add new AI provider configurations",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Edit AI provider settings and custom providers",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove AI provider configurations",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
@@ -606,6 +630,7 @@ const ROLE_PRESETS: {
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getAiSettingById,
|
||||
getAiSettingsByOrganizationId,
|
||||
getCustomAiProviders,
|
||||
maskAiApiKey,
|
||||
mergeAiApiKey,
|
||||
resolveAiApiKey,
|
||||
saveAiSettings,
|
||||
saveCustomAiProviders,
|
||||
suggestVariants,
|
||||
@@ -23,6 +26,7 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
hasPermission,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import {
|
||||
@@ -38,23 +42,48 @@ import { slugify } from "@/lib/slug";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
withAnyPermission,
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
|
||||
const resolveInputApiKey = (
|
||||
input: { apiKey: string; apiUrl: string; aiId?: string },
|
||||
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
|
||||
) =>
|
||||
resolveAiApiKey({
|
||||
apiKey: input.apiKey,
|
||||
apiUrl: input.apiUrl,
|
||||
aiId: input.aiId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
authorize: () => hasPermission(ctx, { ai: ["update"] }),
|
||||
});
|
||||
|
||||
export const aiRouter = createTRPCRouter({
|
||||
one: adminProcedure
|
||||
one: withAnyPermission("ai", ["read", "update"])
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getAiSettingById(input.aiId);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const setting = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return maskAiApiKey(setting);
|
||||
}),
|
||||
|
||||
getModels: protectedProcedure
|
||||
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
getModels: withAnyPermission("ai", ["read", "create", "update"])
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
aiId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const apiKey = await resolveInputApiKey(input, ctx);
|
||||
const providerName = getProviderName(input.apiUrl);
|
||||
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
|
||||
const headers = getProviderHeaders(input.apiUrl, apiKey);
|
||||
let response = null;
|
||||
switch (providerName) {
|
||||
case "ollama":
|
||||
@@ -62,7 +91,7 @@ export const aiRouter = createTRPCRouter({
|
||||
break;
|
||||
case "gemini":
|
||||
response = await fetch(
|
||||
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
|
||||
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
|
||||
{ headers: {} },
|
||||
);
|
||||
break;
|
||||
@@ -125,7 +154,7 @@ export const aiRouter = createTRPCRouter({
|
||||
},
|
||||
] as Model[];
|
||||
default:
|
||||
if (!input.apiKey)
|
||||
if (!apiKey)
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "API key must contain at least 1 character(s)",
|
||||
@@ -177,33 +206,76 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||
}),
|
||||
|
||||
getAll: adminProcedure.query(async ({ ctx }) => {
|
||||
return await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
|
||||
get: adminProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getAiSettingById(input.aiId);
|
||||
create: withPermission("ai", "create")
|
||||
.input(apiCreateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await saveAiSettings(
|
||||
ctx.session.activeOrganizationId,
|
||||
input,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "ai",
|
||||
resourceName: input.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await deleteAiSettings(input.aiId);
|
||||
update: withPermission("ai", "update")
|
||||
.input(apiUpdateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (input.apiKey) {
|
||||
input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey);
|
||||
}
|
||||
const result = await saveAiSettings(
|
||||
ctx.session.activeOrganizationId,
|
||||
input,
|
||||
false,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "ai",
|
||||
resourceId: input.aiId,
|
||||
resourceName: input.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query(
|
||||
async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return settings.map(({ apiKey, ...rest }) => rest);
|
||||
},
|
||||
),
|
||||
|
||||
delete: withPermission("ai", "delete")
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const setting = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await deleteAiSettings(input.aiId, ctx.session.activeOrganizationId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "ai",
|
||||
resourceId: input.aiId,
|
||||
resourceName: setting.name,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
getCustomProviders: withAnyPermission("ai", [
|
||||
"read",
|
||||
"create",
|
||||
"update",
|
||||
]).query(async ({ ctx }) => {
|
||||
return await getCustomAiProviders(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
@@ -216,7 +288,7 @@ export const aiRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
|
||||
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -225,7 +297,7 @@ export const aiRouter = createTRPCRouter({
|
||||
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
|
||||
}),
|
||||
|
||||
analyzeLogs: protectedProcedure
|
||||
analyzeLogs: withPermission("ai", "read")
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string().min(1),
|
||||
@@ -235,7 +307,10 @@ export const aiRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(input.aiId);
|
||||
const aiSettings = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -243,13 +318,6 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Access denied",
|
||||
});
|
||||
}
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
@@ -283,19 +351,21 @@ ${input.logs}`,
|
||||
}
|
||||
}),
|
||||
|
||||
testConnection: protectedProcedure
|
||||
testConnection: withAnyPermission("ai", ["create", "update"])
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
model: z.string().min(1),
|
||||
aiId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const apiKey = await resolveInputApiKey(input, ctx);
|
||||
const provider = selectAIProvider({
|
||||
apiUrl: input.apiUrl,
|
||||
apiKey: input.apiKey,
|
||||
apiKey,
|
||||
});
|
||||
const model = provider(input.model);
|
||||
const result = await generateText({
|
||||
@@ -317,7 +387,7 @@ ${input.logs}`,
|
||||
}
|
||||
}),
|
||||
|
||||
suggest: protectedProcedure
|
||||
suggest: withPermission("ai", "read")
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string(),
|
||||
@@ -338,7 +408,7 @@ ${input.logs}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
deploy: withPermission("ai", "read")
|
||||
.input(deploySuggestionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const environment = await findEnvironmentById(input.environmentId);
|
||||
|
||||
@@ -12,7 +12,10 @@ import { db } from "@dokploy/server/db";
|
||||
import { hasValidLicense } from "@dokploy/server/index";
|
||||
import type { statements } from "@dokploy/server/lib/access-control";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { checkPermission } from "@dokploy/server/services/permission";
|
||||
import {
|
||||
checkPermission,
|
||||
resolvePermissions,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
@@ -82,14 +85,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
return createInnerTRPCContext({
|
||||
req,
|
||||
res,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
session: session
|
||||
? {
|
||||
...session,
|
||||
activeOrganizationId: session.activeOrganizationId || "",
|
||||
}
|
||||
: null,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
user: user
|
||||
? {
|
||||
...user,
|
||||
@@ -263,3 +266,25 @@ export const withPermission = <R extends Resource>(
|
||||
await checkPermission(ctx, { [resource]: [action] } as any);
|
||||
return next();
|
||||
});
|
||||
|
||||
export const withAnyPermission = <R extends Resource>(
|
||||
resource: R,
|
||||
actions: ActionOf<R>[],
|
||||
) =>
|
||||
protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const permissions = await resolvePermissions(ctx);
|
||||
const resourcePerms = permissions[resource];
|
||||
|
||||
if (resourcePerms) {
|
||||
for (const action of actions) {
|
||||
if ((resourcePerms as Record<string, boolean>)[action as string]) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,4 +95,5 @@ export type AuditResourceType =
|
||||
| "network"
|
||||
| "vaultProvider"
|
||||
| "dnsProvider"
|
||||
| "telemetryProvider";
|
||||
| "telemetryProvider"
|
||||
| "ai";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createAccessControl } from "better-auth/plugins/access";
|
||||
* The rest are Dokploy-specific resources.
|
||||
*
|
||||
* Enterprise-only resources (only assignable via custom roles):
|
||||
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
|
||||
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring, ai
|
||||
*/
|
||||
export const statements = {
|
||||
// better-auth organization plugin defaults
|
||||
@@ -51,6 +51,7 @@ export const statements = {
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -79,6 +80,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
"ai",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@@ -121,6 +123,7 @@ export const ownerRole = ac.newRole({
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
telemetryProvider: ["read", "create", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -161,6 +164,7 @@ export const adminRole = ac.newRole({
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
telemetryProvider: ["read", "create", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -207,4 +211,5 @@ export const memberRole = ac.newRole({
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
dnsProvider: [],
|
||||
ai: [],
|
||||
});
|
||||
|
||||
@@ -4,12 +4,22 @@ import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
|
||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { generateText, Output } from "ai";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { findServerById } from "./server";
|
||||
import { getWebServerSettings } from "./web-server-settings";
|
||||
|
||||
export const AI_SECRET_MASK = "********";
|
||||
|
||||
export const maskAiApiKey = <T extends { apiKey: string }>(settings: T): T => ({
|
||||
...settings,
|
||||
apiKey: AI_SECRET_MASK,
|
||||
});
|
||||
|
||||
export const mergeAiApiKey = (incoming: string, existing: string): string =>
|
||||
incoming === AI_SECRET_MASK ? existing : incoming;
|
||||
|
||||
interface SuggestionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,9 +46,12 @@ export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||
return aiSettings;
|
||||
};
|
||||
|
||||
export const getAiSettingById = async (aiId: string) => {
|
||||
export const getAiSettingById = async (
|
||||
aiId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const aiSetting = await db.query.ai.findFirst({
|
||||
where: eq(ai.aiId, aiId),
|
||||
where: and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)),
|
||||
});
|
||||
if (!aiSetting) {
|
||||
throw new TRPCError({
|
||||
@@ -95,11 +108,43 @@ export const saveCustomAiProviders = async (
|
||||
return providers;
|
||||
};
|
||||
|
||||
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||
export const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||
|
||||
export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||
export const resolveAiApiKey = async (opts: {
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
aiId: string | undefined;
|
||||
organizationId: string;
|
||||
authorize: () => Promise<boolean>;
|
||||
}): Promise<string> => {
|
||||
if (opts.apiKey !== AI_SECRET_MASK || !opts.aiId) return opts.apiKey;
|
||||
if (!(await opts.authorize())) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
const stored = await getAiSettingById(opts.aiId, opts.organizationId);
|
||||
if (normalizeApiUrl(stored.apiUrl) !== normalizeApiUrl(opts.apiUrl)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "API URL does not match the stored configuration",
|
||||
});
|
||||
}
|
||||
return stored.apiKey;
|
||||
};
|
||||
|
||||
export const saveAiSettings = async (
|
||||
organizationId: string,
|
||||
settings: any,
|
||||
checkExistence = true,
|
||||
) => {
|
||||
const aiId = settings.aiId;
|
||||
|
||||
if (aiId && checkExistence) {
|
||||
await getAiSettingById(aiId, organizationId);
|
||||
}
|
||||
|
||||
if (settings.apiUrl) {
|
||||
const customProviders = await getCustomAiProviders(organizationId);
|
||||
if (customProviders.length > 0) {
|
||||
@@ -132,8 +177,13 @@ export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAiSettings = async (aiId: string) => {
|
||||
return db.delete(ai).where(eq(ai.aiId, aiId));
|
||||
export const deleteAiSettings = async (
|
||||
aiId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
return db
|
||||
.delete(ai)
|
||||
.where(and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)));
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -144,14 +194,14 @@ interface Props {
|
||||
}
|
||||
|
||||
export const suggestVariants = async ({
|
||||
organizationId: _organizationId,
|
||||
organizationId,
|
||||
aiId,
|
||||
input,
|
||||
serverId,
|
||||
}: Props) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(aiId);
|
||||
if (!aiSettings || !aiSettings.isEnabled) {
|
||||
const aiSettings = await getAiSettingById(aiId, organizationId);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "AI features are not enabled for this configuration",
|
||||
@@ -209,7 +259,6 @@ export const suggestVariants = async ({
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
||||
output: Output.object({ schema: fullSchema }),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
|
||||
|
||||
@@ -106,6 +106,7 @@ export const pipeBetweenServers = async ({
|
||||
bytes += chunk.length;
|
||||
onProgress?.(bytes);
|
||||
});
|
||||
from.stdout.on("error", () => {});
|
||||
from.stdout.pipe(to.stdin);
|
||||
|
||||
const targetExit = to.exit.then((code) => {
|
||||
|
||||
Reference in New Issue
Block a user