fix: share integration transport and enforce access
This commit is contained in:
1 parent
52c82f72db
commit
c2a587bda7
16 files changed
+284
-117
No files matched your search
@@ -1,42 +1,33 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import {
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { integrations } from "@homarr/db/schema";
|
||||
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
|
||||
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetIntegrationKinds, getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
|
||||
import { getCustomWidgetIntegrationConnection } from "@homarr/integrations/custom-widget-source";
|
||||
import type { integrationHttpAuth } from "@homarr/integrations/http-auth";
|
||||
|
||||
import { throwIfActionForbiddenAsync } from "../integration/integration-access";
|
||||
import type { IntegrationHttpContext } from "../integration/integration-http";
|
||||
import {
|
||||
getIntegrationForHttpRequest,
|
||||
getIntegrationHttpCacheVersion,
|
||||
getIntegrationHttpConnection,
|
||||
} from "../integration/integration-http";
|
||||
|
||||
interface SourceContext {
|
||||
db: Database;
|
||||
session: Session | null;
|
||||
}
|
||||
// Keep the portable schema's supported types within the server authentication adapters.
|
||||
customWidgetIntegrationKinds satisfies readonly (keyof typeof integrationHttpAuth)[];
|
||||
|
||||
export async function assertCustomWidgetIntegrationBindings(
|
||||
ctx: SourceContext,
|
||||
ctx: IntegrationHttpContext,
|
||||
sources: Record<string, CustomWidgetSource>,
|
||||
) {
|
||||
for (const source of Object.values(sources)) {
|
||||
if (source.type !== "integration" || !source.integrationId) continue;
|
||||
await resolveIntegration(ctx, source, "full");
|
||||
await resolveIntegration(ctx, source);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveIntegration(
|
||||
ctx: SourceContext,
|
||||
ctx: IntegrationHttpContext,
|
||||
source: Extract<CustomWidgetSource, { type: "integration" }>,
|
||||
permission: "use" | "interact" | "full",
|
||||
) {
|
||||
if (!source.integrationId) {
|
||||
throw new TRPCError({
|
||||
@@ -44,11 +35,7 @@ async function resolveIntegration(
|
||||
message: "Select an existing integration for this widget source",
|
||||
});
|
||||
}
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, source.integrationId), permission);
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, source.integrationId),
|
||||
with: { secrets: true },
|
||||
});
|
||||
const integration = await getIntegrationForHttpRequest(ctx, source.integrationId);
|
||||
if (!integration || integration.kind !== source.integrationKind) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
@@ -59,12 +46,14 @@ async function resolveIntegration(
|
||||
}
|
||||
|
||||
export async function resolveCustomWidgetSource(
|
||||
ctx: SourceContext,
|
||||
ctx: IntegrationHttpContext,
|
||||
source: CustomWidgetSource,
|
||||
request: Pick<CustomJsxRequest, "kind" | "method" | "auth">,
|
||||
getSecrets: () => Array<{ kind: string; value: string }>,
|
||||
): Promise<
|
||||
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix"> & { cacheVersion: string }
|
||||
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets"> & {
|
||||
cacheVersion: string;
|
||||
}
|
||||
> {
|
||||
if (source.type !== "integration") {
|
||||
const authType = getCustomWidgetSourceAuthType(source);
|
||||
@@ -75,32 +64,26 @@ export async function resolveCustomWidgetSource(
|
||||
}
|
||||
return { baseUrl: source.baseUrl, networkScope: source.networkScope, auth, cacheVersion: "" };
|
||||
}
|
||||
let permission = "use" as "use" | "interact" | "full";
|
||||
if (request.kind === "action" || request.method !== "GET") permission = "interact";
|
||||
if (request.method === "DELETE") permission = "full";
|
||||
const integration = await resolveIntegration(ctx, source, permission);
|
||||
const connection = getCustomWidgetIntegrationConnection({
|
||||
...integration,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) })),
|
||||
});
|
||||
let tls: CustomWidgetHttpRequest["tls"];
|
||||
if (new URL(connection.baseUrl).protocol === "https:") {
|
||||
const [ca, hostnames] = await Promise.all([
|
||||
getAllTrustedCertificatesAsync(),
|
||||
getTrustedCertificateHostnamesAsync(),
|
||||
]);
|
||||
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
|
||||
}
|
||||
const integration = await resolveIntegration(ctx, source);
|
||||
const connection = await getIntegrationHttpConnection(integration);
|
||||
return {
|
||||
...connection,
|
||||
auth: request.auth === "none" ? undefined : connection.auth,
|
||||
networkScope: "loopback",
|
||||
pathPrefix: new URL(connection.baseUrl).pathname,
|
||||
tls,
|
||||
cacheVersion: createHash("sha256")
|
||||
.update(JSON.stringify([integration.id, integration.kind, integration.url, integration.secrets]))
|
||||
.digest("hex")
|
||||
.slice(0, 16),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCustomWidgetIntegrationCacheVersions(
|
||||
ctx: IntegrationHttpContext,
|
||||
sources: Record<string, CustomWidgetSource>,
|
||||
) {
|
||||
return Promise.all(
|
||||
Object.entries(sources).flatMap(([sourceId, source]) => {
|
||||
if (source.type !== "integration") return [];
|
||||
return [
|
||||
resolveIntegration(ctx, source)
|
||||
.then((integration) => `${sourceId}:${getIntegrationHttpCacheVersion(integration)}`)
|
||||
.catch(() => `${sourceId}:unavailable`),
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import {
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
|
||||
import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import { IntegrationHttpAuthError, resolveIntegrationHttpAuth } from "@homarr/integrations/http-auth";
|
||||
|
||||
export interface IntegrationHttpContext {
|
||||
db: Database;
|
||||
session: Session | null;
|
||||
}
|
||||
|
||||
export async function getIntegrationForHttpRequest(ctx: IntegrationHttpContext, integrationId: string) {
|
||||
const userId = ctx.session?.user.id ?? "";
|
||||
const groups = await ctx.db.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId) });
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, integrationId),
|
||||
with: {
|
||||
secrets: true,
|
||||
userPermissions: { where: eq(integrationUserPermissions.userId, userId) },
|
||||
groupPermissions: {
|
||||
where: inArray(integrationGroupPermissions.groupId, groups.map(({ groupId }) => groupId).concat("")),
|
||||
},
|
||||
},
|
||||
});
|
||||
// An arbitrary read can expose service credentials or perform a mutation. Native
|
||||
// integration use/interact permissions authorize only Homarr's bounded operations.
|
||||
if (!integration || !constructIntegrationPermissions(integration, ctx.session).hasFullAccess) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Arbitrary integration requests require full integration access",
|
||||
});
|
||||
}
|
||||
return integration;
|
||||
}
|
||||
|
||||
type HttpIntegration = Awaited<ReturnType<typeof getIntegrationForHttpRequest>>;
|
||||
|
||||
export function getIntegrationHttpCacheVersion(integration: HttpIntegration) {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([integration.id, integration.kind, integration.url, integration.secrets]))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
export async function getIntegrationHttpConnection(integration: HttpIntegration) {
|
||||
try {
|
||||
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
|
||||
const auth = resolveIntegrationHttpAuth(integration.kind, secrets);
|
||||
const url = new URL(integration.url);
|
||||
let tls: CustomWidgetHttpRequest["tls"];
|
||||
if (url.protocol === "https:") {
|
||||
const [ca, hostnames] = await Promise.all([
|
||||
getAllTrustedCertificatesAsync(),
|
||||
getTrustedCertificateHostnamesAsync(),
|
||||
]);
|
||||
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
|
||||
}
|
||||
return {
|
||||
baseUrl: integration.url,
|
||||
auth,
|
||||
tls,
|
||||
networkScope: "loopback" as const,
|
||||
pathPrefix: url.pathname,
|
||||
redactSecrets: secrets,
|
||||
cacheVersion: getIntegrationHttpCacheVersion(integration),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof IntegrationHttpAuthError) {
|
||||
throw new TRPCError({ code: error.code, message: error.message });
|
||||
}
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration connection could not be resolved" });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveCustomWidgetSource } from "../custom-widget/source-resolver";
|
||||
import { getCustomWidgetIntegrationCacheVersions, resolveCustomWidgetSource } from "../custom-widget/source-resolver";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { parse as parseSuperJson } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
@@ -173,7 +173,7 @@ const executeRequest = async (
|
||||
cacheTtlSeconds: request.cacheSeconds,
|
||||
}),
|
||||
);
|
||||
return { ...response, connectionCacheVersion: connection.cacheVersion };
|
||||
return response;
|
||||
};
|
||||
|
||||
export const customApiRouter = createTRPCRouter({
|
||||
@@ -186,6 +186,7 @@ export const customApiRouter = createTRPCRouter({
|
||||
|
||||
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
|
||||
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
|
||||
const integrationVersions = await getCustomWidgetIntegrationCacheVersions(ctx, resolved.definition.sources);
|
||||
const loadRequests = Object.entries(resolved.definition.requests).filter(
|
||||
([, request]) => request.kind === "query" && request.trigger === "load",
|
||||
);
|
||||
@@ -197,7 +198,6 @@ export const customApiRouter = createTRPCRouter({
|
||||
requestId,
|
||||
{
|
||||
data: response.data,
|
||||
connectionCacheVersion: response.connectionCacheVersion,
|
||||
status: {
|
||||
loading: false,
|
||||
ok: response.ok,
|
||||
@@ -212,7 +212,6 @@ export const customApiRouter = createTRPCRouter({
|
||||
requestId,
|
||||
{
|
||||
data: null,
|
||||
connectionCacheVersion: "",
|
||||
status: {
|
||||
loading: false,
|
||||
ok: false,
|
||||
@@ -228,7 +227,7 @@ export const customApiRouter = createTRPCRouter({
|
||||
return {
|
||||
type: "customJsx" as const,
|
||||
template: resolved.definition.template,
|
||||
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${entries.map(([, result]) => result.connectionCacheVersion).join(":")}`,
|
||||
queryCacheKey: `${getCustomWidgetCacheVersion(resolved.stored)}:${hashRuntimeParams(resolved.configuration)}:${integrationVersions.join(":")}`,
|
||||
data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])),
|
||||
status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])),
|
||||
options: resolved.configuration,
|
||||
|
||||
Reference in new issue
Block a user