feat: add integration access settings (#725)

* feat: add integration access settings

* fix: typecheck and test issues

* fix: test timeout

* chore: address pull request feedback

* chore: add throw if action forbidden for integration permissions

* fix: unable to create new migrations because of duplicate prevId in sqlite snapshots

* chore: add sqlite migration for integration permissions

* test: add unit tests for integration access

* test: add permission checks to integration router tests

* test: add unit test for integration permissions

* chore: add mysql migration

* fix: format issues
This commit is contained in:
Meier Lukas
2024-07-08 00:00:37 +02:00
committed by GitHub
parent be711149f7
commit 408cdeb5c3
50 changed files with 4392 additions and 615 deletions

View File

@@ -0,0 +1,73 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema/sqlite";
import type { IntegrationPermission } from "@homarr/definitions";
/**
* Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context
* @param integrationWhere where clause for the integration
* @param permission permission required to perform action on integration
*/
export const throwIfActionForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationWhere: SQL<unknown>,
permission: IntegrationPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integration = await db.query.integrations.findFirst({
where: integrationWhere,
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
if (!integration) {
notAllowed();
}
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
}
if (["interact", "use"].includes(permission) && hasInteractAccess) {
return; // As interact access is required and user has interact access, allow
}
if (permission === "use" && hasUseAccess) {
return; // As use access is required and user has use access, allow
}
notAllowed();
};
/**
* This method returns NOT_FOUND to prevent snooping on board existence
* A function is used to use the method without return statement
*/
function notAllowed(): never {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}

View File

@@ -2,17 +2,24 @@ import { TRPCError } from "@trpc/server";
import { decryptSecret, encryptSecret } from "@homarr/common";
import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import { and, createId, eq, inArray } from "@homarr/db";
import {
groupPermissions,
integrationGroupPermissions,
integrations,
integrationSecrets,
integrationUserPermissions,
} from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { validation } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { testConnectionAsync } from "./integration-test-connection";
export const integrationRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
all: protectedProcedure.query(async ({ ctx }) => {
const integrations = await ctx.db.query.integrations.findMany();
return integrations
.map((integration) => ({
@@ -26,7 +33,8 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
byId: publicProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
byId: protectedProcedure.input(validation.integration.byId).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
@@ -60,34 +68,39 @@ export const integrationRouter = createTRPCRouter({
})),
};
}),
create: publicProcedure.input(validation.integration.create).mutation(async ({ ctx, input }) => {
await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
});
create: permissionRequiredProcedure
.requiresPermission("integration-create")
.input(validation.integration.create)
.mutation(async ({ ctx, input }) => {
await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
});
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
});
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
});
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
}),
update: protectedProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
}),
update: publicProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
@@ -146,7 +159,9 @@ export const integrationRouter = createTRPCRouter({
}
}
}),
delete: publicProcedure.input(validation.integration.delete).mutation(async ({ ctx, input }) => {
delete: protectedProcedure.input(validation.integration.delete).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
});
@@ -160,6 +175,119 @@ export const integrationRouter = createTRPCRouter({
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
getIntegrationPermissions: protectedProcedure.input(validation.board.permissions).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const dbGroupPermissions = await ctx.db.query.groupPermissions.findMany({
where: inArray(
groupPermissions.permission,
getPermissionsWithParents(["integration-use-all", "integration-interact-all", "integration-full-all"]),
),
columns: {
groupId: false,
},
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
});
const userPermissions = await ctx.db.query.integrationUserPermissions.findMany({
where: eq(integrationUserPermissions.integrationId, input.id),
with: {
user: {
columns: {
id: true,
name: true,
image: true,
},
},
},
});
const dbGroupIntegrationPermission = await ctx.db.query.integrationGroupPermissions.findMany({
where: eq(integrationGroupPermissions.integrationId, input.id),
with: {
group: {
columns: {
id: true,
name: true,
},
},
},
});
return {
inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
.map(({ user, permission }) => ({
user,
permission,
}))
.sort((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupIntegrationPermission
.map(({ group, permission }) => ({
group: {
id: group.id,
name: group.name,
},
permission,
}))
.sort((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
}),
saveUserIntegrationPermissions: protectedProcedure
.input(validation.integration.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(integrationUserPermissions).values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
}),
saveGroupIntegrationPermissions: protectedProcedure
.input(validation.integration.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => {
await transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId));
if (input.permissions.length === 0) {
return;
}
await transaction.insert(integrationGroupPermissions).values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
);
});
}),
});
interface UpdateSecretInput {