feat: recalculate permission scope and add permission matrix UI
- Gate app list procedures (getPaginated/all/search) behind app-modify-all - Filter integration list procedures by per-integration access - Bundle create permissions with their modify/full parents on group save - Align manage nav, home stat cards and page gates with the new scope - Add permission matrix UI with presets and effective-permission preview - Show scoped empty states and delegated-admin notices on the integrations page
This commit is contained in:
23 files changed
+982
-275
No files matched your search
@@ -18,7 +18,8 @@ import { AppAccessControl } from "./app/app-access-control";
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
@@ -50,7 +51,8 @@ export const appRouter = createTRPCRouter({
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
all: protectedProcedure
|
||||
all: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.input(z.void())
|
||||
.output(z.array(selectAppSchema))
|
||||
.meta({
|
||||
@@ -67,7 +69,8 @@ export const appRouter = createTRPCRouter({
|
||||
orderBy: asc(apps.name),
|
||||
});
|
||||
}),
|
||||
search: protectedProcedure
|
||||
search: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups, onboarding, users } from "@homarr/db/schema";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
@@ -22,6 +23,13 @@ import {
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
const createPermissionParents = {
|
||||
"app-create": "app-modify-all",
|
||||
"integration-create": "integration-full-all",
|
||||
"board-create": "board-modify-all",
|
||||
"search-engine-create": "search-engine-modify-all",
|
||||
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey>>;
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
const dbGroups = await ctx.db.query.groups.findMany({
|
||||
@@ -302,16 +310,52 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
|
||||
const permissions = [
|
||||
...new Set(
|
||||
input.permissions.flatMap((permission) => {
|
||||
const parent = createPermissionParents[permission as keyof typeof createPermissionParents] as
|
||||
| GroupPermissionKey
|
||||
| undefined;
|
||||
return parent ? [permission, parent] : [permission];
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
if (input.permissions.length > 0) {
|
||||
await ctx.db.insert(groupPermissions).values(
|
||||
input.permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
}
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.delete(schema.groupPermissions)
|
||||
.where(eq(schema.groupPermissions.groupId, input.groupId));
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
await transaction.insert(schema.groupPermissions).values(
|
||||
permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId)).run();
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
transaction
|
||||
.insert(groupPermissions)
|
||||
.values(
|
||||
permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
)
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
transferOwnership: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { db, eq, inArray, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
@@ -6,6 +8,8 @@ import {
|
||||
boardUserPermissions,
|
||||
groupMembers,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
invites,
|
||||
medias,
|
||||
@@ -16,6 +20,33 @@ import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId),
|
||||
});
|
||||
|
||||
const accessibleIntegrations = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, userId),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return accessibleIntegrations
|
||||
.filter((integration) => constructIntegrationPermissions(integration, null).hasFullAccess)
|
||||
.map((integration) => integration.id);
|
||||
};
|
||||
|
||||
interface HomeStatistic {
|
||||
titleKey: keyof TranslationObject["management"]["page"]["home"]["statistic"];
|
||||
subtitleKey: keyof TranslationObject["management"]["page"]["home"]["statisticLabel"];
|
||||
@@ -92,16 +123,20 @@ export const homeRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("integration-create")) {
|
||||
const hasGlobalFullIntegrationAccess = ctx.session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
const fullAccessIntegrationIds = hasGlobalFullIntegrationAccess
|
||||
? []
|
||||
: await getFullAccessIntegrationIdsAsync(ctx.db, ctx.session?.user.id ?? "");
|
||||
if (hasGlobalFullIntegrationAccess || fullAccessIntegrationIds.length > 0) {
|
||||
statistics.push({
|
||||
titleKey: "integration",
|
||||
subtitleKey: "resources",
|
||||
count: await db.$count(integrations),
|
||||
count: hasGlobalFullIntegrationAccess ? await db.$count(integrations) : fullAccessIntegrationIds.length,
|
||||
path: "/manage/integrations",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session?.user.permissions.includes("app-create")) {
|
||||
if (ctx.session?.user.permissions.includes("app-modify-all")) {
|
||||
statistics.push({
|
||||
titleKey: "app",
|
||||
subtitleKey: "resources",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
integrationSecrets,
|
||||
integrationUserPermissions,
|
||||
} from "@homarr/db/schema";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIntegrationKindsByCategory,
|
||||
getPermissionsWithParents,
|
||||
@@ -71,80 +72,18 @@ export const integrationRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrations = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
return integrations
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
return await getAccessibleIntegrationsAsync(ctx);
|
||||
}),
|
||||
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
where: inArray(
|
||||
return await getAccessibleIntegrationsAsync(
|
||||
ctx,
|
||||
inArray(
|
||||
integrations.kind,
|
||||
objectEntries(integrationDefs)
|
||||
.filter(([_, integration]) => [...integration.category].includes("search"))
|
||||
.map(([kind, _]) => kind),
|
||||
),
|
||||
});
|
||||
return integrationsFromDb
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
);
|
||||
}),
|
||||
allOfGivenCategory: protectedProcedure
|
||||
.input(
|
||||
@@ -153,42 +92,10 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const intergrationKinds = getIntegrationKindsByCategory(input.category);
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
where: inArray(integrations.kind, intergrationKinds),
|
||||
});
|
||||
return integrationsFromDb
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
return await getAccessibleIntegrationsAsync(
|
||||
ctx,
|
||||
inArray(integrations.kind, getIntegrationKindsByCategory(input.category)),
|
||||
);
|
||||
}),
|
||||
search: protectedProcedure
|
||||
.meta({
|
||||
@@ -205,11 +112,32 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.integrations.findMany({
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where: like(integrations.name, `%${input.query}%`),
|
||||
orderBy: asc(integrations.name),
|
||||
limit: input.limit,
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return integrationsFromDb
|
||||
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
|
||||
.slice(0, input.limit)
|
||||
.map(
|
||||
({ userPermissions: _userPermissions, groupPermissions: _groupPermissions, ...integration }) => integration,
|
||||
);
|
||||
}),
|
||||
// This is used to get the integrations by their ids it's public because it's needed to get integrations data in the boards
|
||||
byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => {
|
||||
@@ -774,6 +702,62 @@ export const integrationRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const globalIntegrationPermissions = [
|
||||
"integration-use-all",
|
||||
"integration-interact-all",
|
||||
"integration-full-all",
|
||||
] satisfies GroupPermissionKey[];
|
||||
|
||||
const hasGlobalIntegrationAccess = (session: Session | null) =>
|
||||
globalIntegrationPermissions.some((permission) => session?.user.permissions.includes(permission) ?? false);
|
||||
|
||||
const hasIntegrationQueryAccess = (
|
||||
session: Session | null,
|
||||
integration: {
|
||||
userPermissions: { permission: IntegrationPermission }[];
|
||||
groupPermissions: { permission: IntegrationPermission }[];
|
||||
},
|
||||
) => hasGlobalIntegrationAccess(session) || constructIntegrationPermissions(integration, session).hasUseAccess;
|
||||
|
||||
const getAccessibleIntegrationsAsync = async (ctx: { db: Database; session: Session }, where?: SQL) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session.user.id),
|
||||
});
|
||||
|
||||
const integrationsFromDb = await ctx.db.query.integrations.findMany({
|
||||
where,
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return integrationsFromDb
|
||||
.filter((integration) => hasIntegrationQueryAccess(ctx.session, integration))
|
||||
.map((integration) => {
|
||||
const permissions = constructIntegrationPermissions(integration, ctx.session);
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions,
|
||||
};
|
||||
})
|
||||
.toSorted(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
);
|
||||
};
|
||||
|
||||
interface IntegrationRouterContext {
|
||||
db: Database;
|
||||
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[2];
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("all should return all apps", () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
|
||||
await db.insert(apps).values([
|
||||
@@ -74,6 +74,20 @@ describe("all should return all apps", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
// Arrange
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.all();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaginated should return apps to authenticated users", () => {
|
||||
@@ -87,12 +101,12 @@ describe("getPaginated should return apps to authenticated users", () => {
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("should return apps without board permissions", async () => {
|
||||
test("should return apps with app-modify-all permission", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
@@ -101,6 +115,28 @@ describe("getPaginated should return apps to authenticated users", () => {
|
||||
items: [{ id: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should return apps to authenticated users", () => {
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.search({ query: "Homarr" })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return an app by id", () => {
|
||||
|
||||
@@ -449,6 +449,89 @@ describe("savePermissions should save permissions for group", () => {
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
|
||||
});
|
||||
|
||||
test("with a create permission it should also persist its parent permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["app-create"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
|
||||
});
|
||||
|
||||
test("with a create permission and its parent it should persist exactly two rows", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["app-create", "app-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(2);
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
|
||||
});
|
||||
|
||||
test("with an empty permission list it should clear all existing permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values([
|
||||
{ groupId, permission: "admin" },
|
||||
{ groupId, permission: "board-full-all" },
|
||||
]);
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(0);
|
||||
});
|
||||
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -58,6 +58,141 @@ describe("all should return all integrations", () => {
|
||||
expect(result[1]!.kind).toBe("homeAssistant");
|
||||
expect(result.every(({ permissions }) => permissions.hasFullAccess && permissions.hasUseAccess)).toBe(true);
|
||||
});
|
||||
|
||||
test("with integration use access should return all integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
test("without global integration access should only return integrations with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("with a per-integration interact grant should return the integration with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "interact",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should return accessible integrations", () => {
|
||||
test("without global integration access should only return integrations with use access as plain rows", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.search({ query: "Home" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]).not.toHaveProperty("userPermissions");
|
||||
expect(result[0]).not.toHaveProperty("groupPermissions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
|
||||
|
||||
Reference in new issue
Block a user