feat: add member sync between groups of ldap and homarr (#1149)
* feat: add member sync between groups of ldap and homarr * chore: remove temporary console statement * test: add unit tests for adding and removing ldap group members
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import { CredentialsSignin } from "@auth/core/errors";
|
import { CredentialsSignin } from "@auth/core/errors";
|
||||||
|
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database, InferInsertModel } from "@homarr/db";
|
||||||
import { and, createId, eq } from "@homarr/db";
|
import { and, createId, eq, inArray } from "@homarr/db";
|
||||||
import { users } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import type { validation } from "@homarr/validation";
|
import type { validation } from "@homarr/validation";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
@@ -99,21 +99,39 @@ export const authorizeWithLdapCredentialsAsync = async (
|
|||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
},
|
},
|
||||||
|
with: {
|
||||||
|
groups: {
|
||||||
|
with: {
|
||||||
|
group: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")),
|
where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.info(`User ${credentials.name} not found in the database. Creating...`);
|
logger.info(`User ${credentials.name} not found in the database. Creating...`);
|
||||||
|
|
||||||
user = {
|
const insertUser = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: credentials.name,
|
name: credentials.name,
|
||||||
email: mailResult.data,
|
email: mailResult.data,
|
||||||
emailVerified: new Date(), // assume email is verified
|
emailVerified: new Date(), // assume email is verified
|
||||||
image: null,
|
image: null,
|
||||||
provider: "ldap",
|
provider: "ldap",
|
||||||
|
} satisfies InferInsertModel<typeof users>;
|
||||||
|
|
||||||
|
await db.insert(users).values(insertUser);
|
||||||
|
|
||||||
|
user = {
|
||||||
|
...insertUser,
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
await db.insert(users).values(user);
|
|
||||||
|
|
||||||
logger.info(`User ${credentials.name} created successfully.`);
|
logger.info(`User ${credentials.name} created successfully.`);
|
||||||
}
|
}
|
||||||
@@ -128,6 +146,58 @@ export const authorizeWithLdapCredentialsAsync = async (
|
|||||||
logger.info(`User ${credentials.name} updated successfully.`);
|
logger.info(`User ${credentials.name} updated successfully.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ldapGroupsUserIsNotIn = userGroups.filter(
|
||||||
|
(group) => !user.groups.some((userGroup) => userGroup.group.name === group),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ldapGroupsUserIsNotIn.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Homarr does not have the user in certain groups. user=${user.name} count=${ldapGroupsUserIsNotIn.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupIds = await db.query.groups.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
where: inArray(groups.name, ldapGroupsUserIsNotIn),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(`Homarr has found groups in the database user is not in. user=${user.name} count=${groupIds.length}`);
|
||||||
|
|
||||||
|
if (groupIds.length > 0) {
|
||||||
|
await db.insert(groupMembers).values(
|
||||||
|
groupIds.map((group) => ({
|
||||||
|
userId: user.id,
|
||||||
|
groupId: group.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Added user to groups successfully. user=${user.name} count=${groupIds.length}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`User is already in all groups of Homarr. user=${user.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const homarrGroupsUserIsNotIn = user.groups.filter((userGroup) => !userGroups.includes(userGroup.group.name));
|
||||||
|
|
||||||
|
if (homarrGroupsUserIsNotIn.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Homarr has the user in certain groups that LDAP does not have. user=${user.name} count=${homarrGroupsUserIsNotIn.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.delete(groupMembers).where(
|
||||||
|
and(
|
||||||
|
eq(groupMembers.userId, user.id),
|
||||||
|
inArray(
|
||||||
|
groupMembers.groupId,
|
||||||
|
homarrGroupsUserIsNotIn.map(({ groupId }) => groupId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Removed user from groups successfully. user=${user.name} count=${homarrGroupsUserIsNotIn.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
|
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, createId, eq } from "@homarr/db";
|
import { and, createId, eq } from "@homarr/db";
|
||||||
import { users } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
import { createSaltAsync, hashPasswordAsync } from "../../security";
|
|
||||||
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
|
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
|
||||||
import * as ldapClient from "../credentials/ldap-client";
|
import * as ldapClient from "../credentials/ldap-client";
|
||||||
|
|
||||||
@@ -15,6 +14,7 @@ vi.mock("../../env.mjs", () => ({
|
|||||||
AUTH_LDAP_BIND_DN: "bind_dn",
|
AUTH_LDAP_BIND_DN: "bind_dn",
|
||||||
AUTH_LDAP_BIND_PASSWORD: "bind_password",
|
AUTH_LDAP_BIND_PASSWORD: "bind_password",
|
||||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: "mail",
|
AUTH_LDAP_USER_MAIL_ATTRIBUTE: "mail",
|
||||||
|
AUTH_LDAP_GROUP_CLASS: "group",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -171,7 +171,6 @@ describe("authorizeWithLdapCredentials", () => {
|
|||||||
// Arrange
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||||
const salt = await createSaltAsync();
|
|
||||||
spy.mockImplementation(
|
spy.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
@@ -190,8 +189,6 @@ describe("authorizeWithLdapCredentials", () => {
|
|||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "test",
|
name: "test",
|
||||||
salt,
|
|
||||||
password: await hashPasswordAsync("test", salt),
|
|
||||||
email: "test@gmail.com",
|
email: "test@gmail.com",
|
||||||
provider: "credentials",
|
provider: "credentials",
|
||||||
});
|
});
|
||||||
@@ -224,14 +221,28 @@ describe("authorizeWithLdapCredentials", () => {
|
|||||||
|
|
||||||
test("should authorize user with correct credentials and update name", async () => {
|
test("should authorize user with correct credentials and update name", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||||
|
spy.mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
bindAsync: vi.fn(() => Promise.resolve()),
|
||||||
|
searchAsync: vi.fn(() =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
dn: "test55",
|
||||||
|
mail: "test@gmail.com",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
disconnectAsync: vi.fn(),
|
||||||
|
}) as unknown as ldapClient.LdapClient,
|
||||||
|
);
|
||||||
|
|
||||||
const userId = createId();
|
const userId = createId();
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const salt = await createSaltAsync();
|
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
name: "test-old",
|
name: "test-old",
|
||||||
salt,
|
|
||||||
password: await hashPasswordAsync("test", salt),
|
|
||||||
email: "test@gmail.com",
|
email: "test@gmail.com",
|
||||||
provider: "ldap",
|
provider: "ldap",
|
||||||
});
|
});
|
||||||
@@ -256,4 +267,127 @@ describe("authorizeWithLdapCredentials", () => {
|
|||||||
expect(dbUser?.email).toBe("test@gmail.com");
|
expect(dbUser?.email).toBe("test@gmail.com");
|
||||||
expect(dbUser?.provider).toBe("ldap");
|
expect(dbUser?.provider).toBe("ldap");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should authorize user with correct credentials and add him to the groups that he is in LDAP but not in Homar", async () => {
|
||||||
|
// Arrange
|
||||||
|
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||||
|
spy.mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
bindAsync: vi.fn(() => Promise.resolve()),
|
||||||
|
searchAsync: vi.fn((argument: { options: { filter: string } }) =>
|
||||||
|
argument.options.filter.includes("group")
|
||||||
|
? Promise.resolve([
|
||||||
|
{
|
||||||
|
cn: "homarr_example",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
: Promise.resolve([
|
||||||
|
{
|
||||||
|
dn: "test55",
|
||||||
|
mail: "test@gmail.com",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
disconnectAsync: vi.fn(),
|
||||||
|
}) as unknown as ldapClient.LdapClient,
|
||||||
|
);
|
||||||
|
const db = createDb();
|
||||||
|
const userId = createId();
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: userId,
|
||||||
|
name: "test",
|
||||||
|
email: "test@gmail.com",
|
||||||
|
provider: "ldap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupId = createId();
|
||||||
|
await db.insert(groups).values({
|
||||||
|
id: groupId,
|
||||||
|
name: "homarr_example",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await authorizeWithLdapCredentialsAsync(db, {
|
||||||
|
name: "test",
|
||||||
|
password: "test",
|
||||||
|
credentialType: "ldap",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual({ id: userId, name: "test" });
|
||||||
|
|
||||||
|
const dbGroupMembers = await db.query.groupMembers.findMany();
|
||||||
|
expect(dbGroupMembers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should authorize user with correct credentials and remove him from groups he is in Homarr but not in LDAP", async () => {
|
||||||
|
// Arrange
|
||||||
|
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||||
|
spy.mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
bindAsync: vi.fn(() => Promise.resolve()),
|
||||||
|
searchAsync: vi.fn((argument: { options: { filter: string } }) =>
|
||||||
|
argument.options.filter.includes("group")
|
||||||
|
? Promise.resolve([
|
||||||
|
{
|
||||||
|
cn: "homarr_example",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
: Promise.resolve([
|
||||||
|
{
|
||||||
|
dn: "test55",
|
||||||
|
mail: "test@gmail.com",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
disconnectAsync: vi.fn(),
|
||||||
|
}) as unknown as ldapClient.LdapClient,
|
||||||
|
);
|
||||||
|
const db = createDb();
|
||||||
|
const userId = createId();
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: userId,
|
||||||
|
name: "test",
|
||||||
|
email: "test@gmail.com",
|
||||||
|
provider: "ldap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupIds = [createId(), createId()] as const;
|
||||||
|
await db.insert(groups).values([
|
||||||
|
{
|
||||||
|
id: groupIds[0],
|
||||||
|
name: "homarr_example",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: groupIds[1],
|
||||||
|
name: "homarr_no_longer_member",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(groupMembers).values([
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
groupId: groupIds[0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
groupId: groupIds[1],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await authorizeWithLdapCredentialsAsync(db, {
|
||||||
|
name: "test",
|
||||||
|
password: "test",
|
||||||
|
credentialType: "ldap",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual({ id: userId, name: "test" });
|
||||||
|
|
||||||
|
const dbGroupMembers = await db.query.groupMembers.findMany();
|
||||||
|
expect(dbGroupMembers).toHaveLength(1);
|
||||||
|
expect(dbGroupMembers[0]?.groupId).toBe(groupIds[0]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user