feat: add local group support for OIDC users (#5952)

This commit is contained in:
Louis Bailleau authored and GitHub committed 2026-06-16 13:45:44 +02:00
1 parent 4692e7ce3a
commit fb18b975b2
15 files changed
+540 -191

No files matched your search

+21 -5
View File
@@ -1,11 +1,12 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { canManageGroupMembersLocally, isGroupMembershipManagedLocally } from "@homarr/auth/server";
import { createId } from "@homarr/common";
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 } from "@homarr/db/schema";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
import {
@@ -18,7 +19,6 @@ import {
} from "@homarr/validation/group";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
export const groupRouter = createTRPCRouter({
@@ -315,10 +315,10 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
throwIfGroupMembersCannotBeManagedLocally();
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
where: eq(users.id, input.userId),
});
if (!user) {
@@ -328,6 +328,13 @@ export const groupRouter = createTRPCRouter({
});
}
if (!isGroupMembershipManagedLocally(user.provider)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User's provider is not managed locally",
});
}
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
@@ -339,7 +346,7 @@ export const groupRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
throwIfCredentialsDisabled();
throwIfGroupMembersCannotBeManagedLocally();
await ctx.db
.delete(groupMembers)
@@ -371,6 +378,15 @@ const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
}
};
const throwIfGroupMembersCannotBeManagedLocally = () => {
if (!canManageGroupMembersLocally()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Group members cannot be managed locally",
});
}
};
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
const group = await db.query.groups.findFirst({
where: eq(groups.id, id),
+40 -2
View File
@@ -748,7 +748,45 @@ describe("addMember should add member to group", () => {
});
// Assert
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
});
test("with a user from an externally managed provider it should throw FORBIDDEN error", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(env, "env", "get");
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials", "ldap"] } as never);
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
provider: "ldap",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
position: 1,
});
// Act
const actAsync = async () =>
await caller.addMember({
groupId,
userId,
});
// Assert
await expect(actAsync()).rejects.toThrow("User's provider is not managed locally");
});
});
@@ -872,7 +910,7 @@ describe("removeMember should remove member from group", () => {
});
// Assert
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
});
});
+4 -4
View File
@@ -7,12 +7,12 @@ import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, eq, handleTransactionsAsync, like } from "@homarr/db";
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import {
@@ -257,7 +257,7 @@ export const userRouter = createTRPCRouter({
}),
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
.input(z.object({ providers: z.array(z.enum(supportedAuthProviders)).optional() }).optional())
.output(
z.array(
selectUserSchema.pick({
@@ -284,7 +284,7 @@ export const userRouter = createTRPCRouter({
image: true,
email: true,
},
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
where: input?.providers ? inArray(users.provider, input.providers) : undefined,
});
}),
search: permissionRequiredProcedure