feat: add integration access check to middlewares (#756)

* feat: add integration access check to middlewares

* fix: format issues

* fix: remove group and user permissions and items from context

* refactor: move action check to seperate function
This commit is contained in:
Meier Lukas
2024-07-08 17:39:36 +02:00
committed by GitHub
parent 8d42ca8b5e
commit 46943b147a
11 changed files with 966 additions and 29 deletions

View File

@@ -1,6 +1,10 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { decryptSecret } from "@homarr/common";
import type { Database } from "@homarr/db";
import { and, eq, inArray } from "@homarr/db";
import { integrations } from "@homarr/db/schema/sqlite";
import type { IntegrationKind } from "@homarr/definitions";
@@ -8,12 +12,41 @@ import { z } from "@homarr/validation";
import { publicProcedure } from "../trpc";
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
type IntegrationAction = "query" | "interact";
/**
* Creates a middleware that provides the integration in the context that is of the specified kinds
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.unstable_concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
*/
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided
) => {
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
with: {
secrets: true,
groupPermissions: true,
userPermissions: true,
items: {
with: {
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
},
});
@@ -24,7 +57,16 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(..
});
}
const { secrets, kind, ...rest } = integration;
await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
const {
secrets,
kind,
items: _ignore1,
groupPermissions: _ignore2,
userPermissions: _ignore3,
...rest
} = integration;
return next({
ctx: {
@@ -41,7 +83,20 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(..
});
};
export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
/**
* Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item
* It also ensures that the user has permission to perform the specified action on the integrations
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.unstable_concat(createManyIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
*/
export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided
) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1) }))
.use(async ({ ctx, input, next }) => {
@@ -49,7 +104,21 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(.
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
items: true,
items: {
with: {
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
userPermissions: true,
groupPermissions: true,
},
});
@@ -61,22 +130,39 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(.
});
}
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
return next({
ctx: {
integrations: dbIntegrations.map(({ secrets, kind, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
})),
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
};
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
/**
* Creates a middleware that provides the integrations and their items in the context that are of the specified kinds and have the specified item
* It also ensures that the user has permission to perform the specified action on the integrations
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration for the item was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
*/
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided
) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() }))
.use(async ({ ctx, input, next }) => {
@@ -84,7 +170,21 @@ export const createManyIntegrationOfOneItemMiddleware = <TKind extends Integrati
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
items: true,
items: {
with: {
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
userPermissions: true,
groupPermissions: true,
},
});
@@ -96,6 +196,8 @@ export const createManyIntegrationOfOneItemMiddleware = <TKind extends Integrati
});
}
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
const dbIntegrationWithItem = dbIntegrations.filter((integration) =>
integration.items.some((item) => item.itemId === input.itemId),
);
@@ -109,15 +211,53 @@ export const createManyIntegrationOfOneItemMiddleware = <TKind extends Integrati
return next({
ctx: {
integrations: dbIntegrationWithItem.map(({ secrets, kind, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
})),
integrations: dbIntegrationWithItem.map(
({ secrets, kind, groupPermissions: _ignore1, userPermissions: _ignore2, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
};
/**
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
* @param action action to perform
* @param db db instance
* @param integrations integrations to check permissions for
* @param session session of the user
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
*/
const throwIfActionIsNotAllowedAsync = async (
action: IntegrationAction,
db: Database,
integrations: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
session: Session | null,
) => {
if (action === "interact") {
const haveAllInteractAccess = integrations
.map((integration) => constructIntegrationPermissions(integration, session))
.every(({ hasInteractAccess }) => hasInteractAccess);
if (haveAllInteractAccess) return;
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to interact with at least one of the specified integrations",
});
}
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
if (hasQueryAccess) return;
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to query at least one of the specified integration",
});
};

View File

@@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.unstable_concat(createManyIntegrationOfOneItemMiddleware("sonarr", "radarr", "readarr", "lidarr"))
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "sonarr", "radarr", "readarr", "lidarr"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.flatMap(async (integration) => {

View File

@@ -9,7 +9,7 @@ import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("piHole")).query(async ({ ctx }) => {
summary: publicProcedure.unstable_concat(createOneIntegrationMiddleware("query", "piHole")).query(async ({ ctx }) => {
const cache = createCacheChannel<DnsHoleSummary>(`dns-hole-summary:${ctx.integration.id}`);
const { data } = await cache.consumeAsync(async () => {

View File

@@ -8,7 +8,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("jellyfin", "plex"))
.unstable_concat(createManyIntegrationMiddleware("query", "jellyfin", "plex"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -22,7 +22,7 @@ export const mediaServerRouter = createTRPCRouter({
);
}),
subscribeToCurrentStreams: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("jellyfin", "plex"))
.unstable_concat(createManyIntegrationMiddleware("query", "jellyfin", "plex"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
const unsubscribes: (() => void)[] = [];

View File

@@ -26,14 +26,14 @@ export const smartHomeRouter = createTRPCRouter({
});
}),
switchEntity: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("homeAssistant"))
.unstable_concat(createOneIntegrationMiddleware("interact", "homeAssistant"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx, input }) => {
const client = new HomeAssistantIntegration(ctx.integration);
return await client.triggerToggleAsync(input.entityId);
}),
executeAutomation: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("homeAssistant"))
.unstable_concat(createOneIntegrationMiddleware("interact", "homeAssistant"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ input, ctx }) => {
const client = new HomeAssistantIntegration(ctx.integration);