feat(16-03): add syncBoundGroupsForTenant reconciliation method
- New private LdapService.syncBoundGroupsForTenant(): rename detection (SC-3), disappearance deletion with default-marker handoff before delete (SC-4/D-05/D-06), legacy ldapDn-only binding GUID backfill (D-07), and a 32-hex-char guard before any objectGUID filter interpolation (T-16-01) - LdapSyncResult grows additively: groupsAdopted, groupsRenamed, groupsDeleted, defaultMarkerMoved - LdapService constructor takes GroupsService; LdapModule imports GroupsModule (no cycle) - 14 new test cases covering the full behavior matrix plus idempotency
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { GroupsModule } from '../groups/groups.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { LdapConfigService } from './ldap-config.service';
|
import { LdapConfigService } from './ldap-config.service';
|
||||||
import { LdapSyncScheduler } from './ldap-sync.scheduler';
|
import { LdapSyncScheduler } from './ldap-sync.scheduler';
|
||||||
@@ -11,9 +12,14 @@ import { LdapService } from './ldap.service';
|
|||||||
*
|
*
|
||||||
* Provides per-tenant LDAP configuration (D-18), manual sync (D-14),
|
* Provides per-tenant LDAP configuration (D-18), manual sync (D-14),
|
||||||
* auto-sync scheduler (D-14), and configurable field mapping (D-16/D-17).
|
* auto-sync scheduler (D-14), and configurable field mapping (D-16/D-17).
|
||||||
|
*
|
||||||
|
* GroupsModule is imported so LdapService can call
|
||||||
|
* GroupsService.reassignDefaultBeforeDelete()/ensureDefaultGroup() from
|
||||||
|
* syncBoundGroupsForTenant() (Plan 16-03, D-06) — no cycle: GroupsModule
|
||||||
|
* imports neither LdapModule nor UserModule.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ScheduleModule.forRoot(), UserModule],
|
imports: [ScheduleModule.forRoot(), UserModule, GroupsModule],
|
||||||
controllers: [LdapController],
|
controllers: [LdapController],
|
||||||
providers: [LdapService, LdapConfigService, LdapSyncScheduler],
|
providers: [LdapService, LdapConfigService, LdapSyncScheduler],
|
||||||
exports: [LdapService, LdapConfigService],
|
exports: [LdapService, LdapConfigService],
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ describe('LdapService.syncUsersForTenant — per-user exclude list', () => {
|
|||||||
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
userService = { create: vi.fn().mockResolvedValue({}) };
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
service = new LdapService(prisma, userService);
|
service = new LdapService(prisma, userService, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports every user when the exclude list is empty', async () => {
|
it('imports every user when the exclude list is empty', async () => {
|
||||||
@@ -162,7 +162,7 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => {
|
|||||||
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
userService = { create: vi.fn().mockResolvedValue({}) };
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
service = new LdapService(prisma, userService);
|
service = new LdapService(prisma, userService, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates nobody and deactivates nobody when the base DN is empty (whitespace-only)', async () => {
|
it('creates nobody and deactivates nobody when the base DN is empty (whitespace-only)', async () => {
|
||||||
@@ -177,6 +177,10 @@ describe('LdapService.syncUsersForTenant — empty base DN no-op', () => {
|
|||||||
deactivated: 0,
|
deactivated: 0,
|
||||||
groupMembershipsAdded: 0,
|
groupMembershipsAdded: 0,
|
||||||
groupMembershipsRemoved: 0,
|
groupMembershipsRemoved: 0,
|
||||||
|
groupsAdopted: 0,
|
||||||
|
groupsRenamed: 0,
|
||||||
|
groupsDeleted: 0,
|
||||||
|
defaultMarkerMoved: 0,
|
||||||
errors: [],
|
errors: [],
|
||||||
});
|
});
|
||||||
expect(mockSearch).not.toHaveBeenCalled();
|
expect(mockSearch).not.toHaveBeenCalled();
|
||||||
@@ -239,7 +243,7 @@ describe('LdapService.syncUsersForTenant — multi base DN scope', () => {
|
|||||||
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
userService = { create: vi.fn().mockResolvedValue({}) };
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
service = new LdapService(prisma, userService);
|
service = new LdapService(prisma, userService, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('searches every configured base DN and merges/dedupes results by dn', async () => {
|
it('searches every configured base DN and merges/dedupes results by dn', async () => {
|
||||||
@@ -313,7 +317,7 @@ describe('LdapService — individual user search & import (dedup)', () => {
|
|||||||
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
userService = { create: vi.fn().mockResolvedValue({}) };
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
service = new LdapService(prisma, userService);
|
service = new LdapService(prisma, userService, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('searchUsers flags results already present by username or ldapDn', async () => {
|
it('searchUsers flags results already present by username or ldapDn', async () => {
|
||||||
@@ -439,7 +443,7 @@ describe('LdapService.testConnection — TLS verification opt-out (ldaps)', () =
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockBind.mockResolvedValue(undefined);
|
mockBind.mockResolvedValue(undefined);
|
||||||
mockUnbind.mockResolvedValue(undefined);
|
mockUnbind.mockResolvedValue(undefined);
|
||||||
service = new LdapService({} as any, {} as any);
|
service = new LdapService({} as any, {} as any, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes tlsOptions.rejectUnauthorized=false for ldaps when opted out', async () => {
|
it('passes tlsOptions.rejectUnauthorized=false for ldaps when opted out', async () => {
|
||||||
@@ -480,7 +484,7 @@ describe('LdapService.verifyUserCredentials — LDAP login bind', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockUnbind.mockResolvedValue(undefined);
|
mockUnbind.mockResolvedValue(undefined);
|
||||||
service = new LdapService({} as any, {} as any);
|
service = new LdapService({} as any, {} as any, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true when the user bind succeeds', async () => {
|
it('returns true when the user bind succeeds', async () => {
|
||||||
@@ -639,7 +643,7 @@ describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-1
|
|||||||
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
ldapConfig: { update: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
userService = { create: vi.fn().mockResolvedValue({}) };
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
service = new LdapService(prisma, userService);
|
service = new LdapService(prisma, userService, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs no additional LDAP search for a tenant without AD-bound groups', async () => {
|
it('runs no additional LDAP search for a tenant without AD-bound groups', async () => {
|
||||||
@@ -875,6 +879,511 @@ describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-1
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verzeichnis (SC-3/SC-4/SC-5, D-05/D-06)', () => {
|
||||||
|
let service: LdapService;
|
||||||
|
let prisma: any;
|
||||||
|
let userService: any;
|
||||||
|
let groupsService: any;
|
||||||
|
let client: any;
|
||||||
|
|
||||||
|
// Hand-rolled in-memory fake for Group (project pattern — see the D-21
|
||||||
|
// block above), so update()/delete() and the OR-candidate query behave
|
||||||
|
// exactly like the real forTenant()-scoped Prisma calls this method
|
||||||
|
// issues, across multiple sequential runs (idempotency test below).
|
||||||
|
let groups: {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
ldapDn: string | null;
|
||||||
|
ldapObjectGuid: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
id: 'cfg1',
|
||||||
|
tenantId: 't1',
|
||||||
|
serverUrl: 'ldap://example',
|
||||||
|
baseDn: 'dc=example,dc=com',
|
||||||
|
searchFilter: '(objectClass=person)',
|
||||||
|
groupFilterDns: [] as string[],
|
||||||
|
userExcludeList: [] as string[],
|
||||||
|
fieldMappings: [{ ldapField: 'sAMAccountName', tesseraField: 'username' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 16 raw bytes, hex-decodable to a stable 32-char lowercase string —
|
||||||
|
// stands in for a real AD objectGUID. NOTE: deliberately its own literal,
|
||||||
|
// not shared with the group-import block below — that block's fixture
|
||||||
|
// string is actually 31 hex characters (an existing off-by-one from Plan
|
||||||
|
// 16-01 that never mattered there because importGroupsByDn() never
|
||||||
|
// length-validates), which would fail this method's 32-char guard.
|
||||||
|
const guidBuffer = Buffer.from('0123456789abcdef'.repeat(2), 'hex');
|
||||||
|
const guidHex = guidBuffer.toString('hex');
|
||||||
|
|
||||||
|
const makeResult = (): any => ({
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
deactivated: 0,
|
||||||
|
groupMembershipsAdded: 0,
|
||||||
|
groupMembershipsRemoved: 0,
|
||||||
|
groupsAdopted: 0,
|
||||||
|
groupsRenamed: 0,
|
||||||
|
groupsDeleted: 0,
|
||||||
|
defaultMarkerMoved: 0,
|
||||||
|
errors: [] as string[],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct invocation of the private method (not yet wired into
|
||||||
|
// syncUsersForTenant — that wiring is Plan 16-03 Task 2, tested
|
||||||
|
// separately below via a dedicated ordering test).
|
||||||
|
const run = (result: any) =>
|
||||||
|
(service as any).syncBoundGroupsForTenant(client, cfg, 't1', result);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
groups = [];
|
||||||
|
client = new Client({} as any);
|
||||||
|
|
||||||
|
prisma = {
|
||||||
|
group: {
|
||||||
|
findMany: vi.fn((args: any) =>
|
||||||
|
Promise.resolve(
|
||||||
|
groups.filter(
|
||||||
|
(g) =>
|
||||||
|
g.tenantId === args.where.tenantId &&
|
||||||
|
(g.ldapObjectGuid !== null || g.ldapDn !== null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
update: vi.fn((args: any) => {
|
||||||
|
const g = groups.find((x) => x.id === args.where.id);
|
||||||
|
if (g) {
|
||||||
|
Object.assign(g, args.data);
|
||||||
|
}
|
||||||
|
return Promise.resolve(g);
|
||||||
|
}),
|
||||||
|
delete: vi.fn((args: any) => {
|
||||||
|
const idx = groups.findIndex((x) => x.id === args.where.id);
|
||||||
|
if (idx === -1) {
|
||||||
|
const err: any = new Error('Record to delete does not exist.');
|
||||||
|
err.code = 'P2025';
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
const [removed] = groups.splice(idx, 1);
|
||||||
|
return Promise.resolve(removed);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
|
groupsService = {
|
||||||
|
reassignDefaultBeforeDelete: vi.fn().mockResolvedValue(false),
|
||||||
|
ensureDefaultGroup: vi.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
service = new LdapService(prisma, userService, groupsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns immediately with no client.search call when the tenant has no candidate group (SC-5)', async () => {
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(mockSearch).not.toHaveBeenCalled();
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a purely local group (ldapObjectGuid: null, ldapDn: null) is excluded by the candidate query and never touched', async () => {
|
||||||
|
groups = [
|
||||||
|
{ id: 'g-local', tenantId: 't1', name: 'Local', ldapDn: null, ldapObjectGuid: null, isDefault: false },
|
||||||
|
];
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(mockSearch).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.group.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.group.delete).not.toHaveBeenCalled();
|
||||||
|
expect(groups).toEqual([
|
||||||
|
{ id: 'g-local', tenantId: 't1', name: 'Local', ldapDn: null, ldapObjectGuid: null, isDefault: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a hit with unchanged cn/dn makes no write and increments no counter', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(prisma.group.update).not.toHaveBeenCalled();
|
||||||
|
expect(result.groupsRenamed).toBe(0);
|
||||||
|
expect(result.groupsDeleted).toBe(0);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a hit with a changed cn/dn updates name and ldapDn and increments groupsRenamed, never writing internalName (SC-3, D-04)', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [{ dn: 'cn=Vertrieb,dc=example,dc=com', cn: 'Vertrieb' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(prisma.group.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'g1' },
|
||||||
|
data: { name: 'Vertrieb', ldapDn: 'cn=Vertrieb,dc=example,dc=com' },
|
||||||
|
});
|
||||||
|
expect(result.groupsRenamed).toBe(1);
|
||||||
|
expect(groups[0].name).toBe('Vertrieb');
|
||||||
|
expect(groups[0].ldapDn).toBe('cn=Vertrieb,dc=example,dc=com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a rename colliding with an existing local name (P2002) is reported and the group is left unchanged, run continues', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'g2',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'IT',
|
||||||
|
ldapDn: 'cn=IT,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
prisma.group.update = vi.fn((args: any) => {
|
||||||
|
if (args.where.id === 'g1') {
|
||||||
|
const err: any = new Error('Unique constraint');
|
||||||
|
err.code = 'P2002';
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
const g = groups.find((x) => x.id === args.where.id);
|
||||||
|
if (g) {
|
||||||
|
Object.assign(g, args.data);
|
||||||
|
}
|
||||||
|
return Promise.resolve(g);
|
||||||
|
});
|
||||||
|
// g1's AD search hit renames it to "IT" (collides with g2's stored
|
||||||
|
// name); g2's own AD search hit renames it away to "IT-Extern" — both
|
||||||
|
// candidates genuinely change, so both reach the update() call and the
|
||||||
|
// "run continues" claim is observable (2 update attempts, not 1).
|
||||||
|
mockSearch
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
searchEntries: [{ dn: 'cn=IT,dc=example,dc=com', cn: 'IT' }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
searchEntries: [
|
||||||
|
{ dn: 'cn=IT-Extern,dc=example,dc=com', cn: 'IT-Extern' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(result.errors).toEqual([
|
||||||
|
"Gruppe Sales: Umbenennung nach 'IT' kollidiert mit einer bestehenden Gruppe",
|
||||||
|
]);
|
||||||
|
expect(result.groupsRenamed).toBe(1);
|
||||||
|
expect(groups[0].name).toBe('Sales');
|
||||||
|
expect(groups[1].name).toBe('IT-Extern');
|
||||||
|
// The second candidate was still processed (run continues).
|
||||||
|
expect(prisma.group.update).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no hit, not the default group, deletes it and increments groupsDeleted without moving the marker', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'g-other',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Alle Benutzer',
|
||||||
|
ldapDn: null,
|
||||||
|
ldapObjectGuid: null,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockSearch.mockResolvedValue({ searchEntries: [] });
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(groupsService.reassignDefaultBeforeDelete).toHaveBeenCalledWith('t1', 'g1');
|
||||||
|
expect(prisma.group.delete).toHaveBeenCalledWith({ where: { id: 'g1' } });
|
||||||
|
expect(result.groupsDeleted).toBe(1);
|
||||||
|
expect(result.defaultMarkerMoved).toBe(0);
|
||||||
|
expect(groups.find((g) => g.id === 'g1')).toBeUndefined();
|
||||||
|
// A deletion happened this run — ensureDefaultGroup runs once as the
|
||||||
|
// Pitfall-5 fallback, even though a target already existed.
|
||||||
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no hit, IS the default group, another group exists — handoff runs BEFORE the delete and increments defaultMarkerMoved (D-06)', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
groupsService.reassignDefaultBeforeDelete.mockResolvedValue(true);
|
||||||
|
mockSearch.mockResolvedValue({ searchEntries: [] });
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
groupsService.reassignDefaultBeforeDelete.mockImplementation(async () => {
|
||||||
|
callOrder.push('handoff');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
prisma.group.delete = vi.fn((args: any) => {
|
||||||
|
callOrder.push('delete');
|
||||||
|
const idx = groups.findIndex((x) => x.id === args.where.id);
|
||||||
|
const [removed] = groups.splice(idx, 1);
|
||||||
|
return Promise.resolve(removed);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(['handoff', 'delete']);
|
||||||
|
expect(result.defaultMarkerMoved).toBe(1);
|
||||||
|
expect(result.groupsDeleted).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no hit, is the ONLY group of the tenant — after the delete, ensureDefaultGroup rebuilds the default group', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
groupsService.reassignDefaultBeforeDelete.mockResolvedValue(false);
|
||||||
|
mockSearch.mockResolvedValue({ searchEntries: [] });
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(result.groupsDeleted).toBe(1);
|
||||||
|
expect(groups).toEqual([]);
|
||||||
|
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a P2025 on the delete (already gone, concurrent manual delete) is swallowed and not double-counted', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockSearch.mockResolvedValue({ searchEntries: [] });
|
||||||
|
prisma.group.delete = vi.fn(() => {
|
||||||
|
const err: any = new Error('Record to delete does not exist.');
|
||||||
|
err.code = 'P2025';
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(result.groupsDeleted).toBe(0);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a legacy binding (ldapDn set, no ldapObjectGuid) whose DN still resolves is backfilled, groupsAdopted increments, and it is reconciled in the same pass (D-07)', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: null,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockSearch.mockImplementation((_dn: string, opts: any) => {
|
||||||
|
if (opts.scope === 'base') {
|
||||||
|
return Promise.resolve({
|
||||||
|
searchEntries: [
|
||||||
|
{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales', objectGUID: guidBuffer },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Existence sweep: same, unchanged group — no rename.
|
||||||
|
return Promise.resolve({
|
||||||
|
searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(result.groupsAdopted).toBe(1);
|
||||||
|
expect(groups[0].ldapObjectGuid).toBe(guidHex);
|
||||||
|
// Only the adoption write happened — cn/dn were already current, no
|
||||||
|
// rename update on top of it.
|
||||||
|
expect(prisma.group.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.group.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'g1' },
|
||||||
|
data: { ldapObjectGuid: guidHex },
|
||||||
|
});
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a legacy binding whose DN no longer resolves is NOT deleted — error line only (D-07/T-16-11)', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: null,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockSearch.mockResolvedValue({ searchEntries: [] });
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(prisma.group.delete).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.group.update).not.toHaveBeenCalled();
|
||||||
|
expect(result.groupsDeleted).toBe(0);
|
||||||
|
expect(result.groupsAdopted).toBe(0);
|
||||||
|
expect(result.errors).toEqual([
|
||||||
|
'Gruppe Sales: Alt-Bindung cn=Sales,dc=example,dc=com laesst sich nicht mehr aufloesen',
|
||||||
|
]);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an invalid stored ldapObjectGuid (not 32 [0-9a-f] chars) never reaches a filter — error line only', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: 'not-a-valid-hex-guid',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(mockSearch).not.toHaveBeenCalled();
|
||||||
|
expect(result.errors).toEqual([
|
||||||
|
'Gruppe Sales: ungueltiger ldapObjectGuid-Wert',
|
||||||
|
]);
|
||||||
|
expect(prisma.group.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a client.search exception for one group is recorded with the group name in result.errors, remaining groups still processed', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g-broken',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Broken',
|
||||||
|
ldapDn: 'cn=Broken,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'g-ok',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'OK',
|
||||||
|
ldapDn: 'cn=OK,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockSearch.mockImplementation((_baseDn: string, opts: any) => {
|
||||||
|
if (opts.filter.includes(LdapService.escapeLdapFilterBuffer(guidBuffer))) {
|
||||||
|
return Promise.reject(new Error('directory unavailable'));
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
searchEntries: [{ dn: 'cn=OK,dc=example,dc=com', cn: 'OK' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = makeResult();
|
||||||
|
await run(result);
|
||||||
|
|
||||||
|
expect(result.errors).toEqual(['Gruppe Broken: directory unavailable']);
|
||||||
|
// The healthy group was still processed (no write needed — unchanged).
|
||||||
|
expect(groups.find((g) => g.id === 'g-ok')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent: a second run over an unchanged AD state issues no group.update or group.delete call', async () => {
|
||||||
|
groups = [
|
||||||
|
{
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Sales',
|
||||||
|
ldapDn: 'cn=Sales,dc=example,dc=com',
|
||||||
|
ldapObjectGuid: guidHex,
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockSearch.mockResolvedValue({
|
||||||
|
searchEntries: [{ dn: 'cn=Sales,dc=example,dc=com', cn: 'Sales' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await run(makeResult());
|
||||||
|
prisma.group.update.mockClear();
|
||||||
|
prisma.group.delete.mockClear();
|
||||||
|
|
||||||
|
const second = makeResult();
|
||||||
|
await run(second);
|
||||||
|
|
||||||
|
expect(prisma.group.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.group.delete).not.toHaveBeenCalled();
|
||||||
|
expect(second.groupsRenamed).toBe(0);
|
||||||
|
expect(second.groupsDeleted).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {
|
describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {
|
||||||
let service: LdapService;
|
let service: LdapService;
|
||||||
let prisma: any;
|
let prisma: any;
|
||||||
@@ -908,7 +1417,7 @@ describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
userService = { create: vi.fn().mockResolvedValue({}) };
|
userService = { create: vi.fn().mockResolvedValue({}) };
|
||||||
service = new LdapService(prisma, userService);
|
service = new LdapService(prisma, userService, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('importGroupsByDn creates a Group with name/ldapDn/ldapObjectGuid and counts imported', async () => {
|
it('importGroupsByDn creates a Group with name/ldapDn/ldapObjectGuid and counts imported', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Client, Entry } from 'ldapts';
|
import { Client, Entry } from 'ldapts';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { GroupsService } from '../groups/groups.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +17,19 @@ export interface LdapSyncResult {
|
|||||||
// sync job, no second button).
|
// sync job, no second button).
|
||||||
groupMembershipsAdded: number;
|
groupMembershipsAdded: number;
|
||||||
groupMembershipsRemoved: number;
|
groupMembershipsRemoved: number;
|
||||||
|
// Plan 16-03: how many bound Groups this run adopted (legacy ldapDn-only
|
||||||
|
// binding from Plan 15-06, backfilled with ldapObjectGuid, D-07), renamed
|
||||||
|
// to match the current AD cn/dn (SC-3), deleted because the AD group
|
||||||
|
// disappeared (SC-4/D-05), and how many times the default-group marker
|
||||||
|
// moved to another group before one of those deletions (D-06).
|
||||||
|
// "groupsAdopted" NOT "groupsImported": this sync never imports a group
|
||||||
|
// (D-02, deliberate deviation from the UI-SPEC field name — see
|
||||||
|
// 16-03-PLAN.md decisions) — it only takes an existing legacy binding
|
||||||
|
// under management.
|
||||||
|
groupsAdopted: number;
|
||||||
|
groupsRenamed: number;
|
||||||
|
groupsDeleted: number;
|
||||||
|
defaultMarkerMoved: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +123,7 @@ export class LdapService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
|
private groupsService: GroupsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -756,6 +771,10 @@ export class LdapService {
|
|||||||
deactivated: 0,
|
deactivated: 0,
|
||||||
groupMembershipsAdded: 0,
|
groupMembershipsAdded: 0,
|
||||||
groupMembershipsRemoved: 0,
|
groupMembershipsRemoved: 0,
|
||||||
|
groupsAdopted: 0,
|
||||||
|
groupsRenamed: 0,
|
||||||
|
groupsDeleted: 0,
|
||||||
|
defaultMarkerMoved: 0,
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1119,6 +1138,214 @@ export class LdapService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-07/SC-3/SC-4/SC-5/D-05/D-06: reconcile every AD-bound Group against
|
||||||
|
* the current directory state — the piece the D-21 membership sync above
|
||||||
|
* depends on already being correct. MUST run BEFORE
|
||||||
|
* syncGroupMembershipsForTenant() in the same pass (wired as step 5a in
|
||||||
|
* syncUsersForTenant, Plan 16-03 Task 2): that step reads Group.ldapDn to
|
||||||
|
* build its memberOf filter, so a rename that has not been written back
|
||||||
|
* yet would make every LDAP membership of the renamed group look removed
|
||||||
|
* (RESEARCH.md Pitfall 1).
|
||||||
|
*
|
||||||
|
* Candidates are every Group with EITHER ldapObjectGuid OR ldapDn set —
|
||||||
|
* the OR is the D-07 hookup for legacy bindings from Plan 15-06 that
|
||||||
|
* predate ldapObjectGuid. A Group with both columns null (never bound, or
|
||||||
|
* a purely local group) never enters this query (SC-5) and is never
|
||||||
|
* touched by any write in this method.
|
||||||
|
*
|
||||||
|
* Per candidate, in order:
|
||||||
|
* 1. Legacy-binding backfill (ldapDn set, ldapObjectGuid still null): one
|
||||||
|
* base-scoped lookup on the stored DN. A hit backfills
|
||||||
|
* ldapObjectGuid (groupsAdopted++) and the candidate is reconciled
|
||||||
|
* normally in the SAME pass below. No hit is NOT a deletion — without
|
||||||
|
* a stable key a deletion here would be a guess, not proof (T-16-11) —
|
||||||
|
* it is an error line and the candidate is skipped.
|
||||||
|
* 2. Existence sweep: the stored hex ldapObjectGuid is validated as
|
||||||
|
* exactly 32 [0-9a-f] characters BEFORE it is ever turned into a
|
||||||
|
* filter (T-16-01) — an invalid value is an error line, never a filter
|
||||||
|
* interpolation. A valid value is turned back into a Buffer and
|
||||||
|
* byte-wise escaped via escapeLdapFilterBuffer() into an
|
||||||
|
* (objectGUID=...) filter, searched across every configured base DN.
|
||||||
|
* 3. A hit whose cn/dn differ from the stored name/ldapDn is a rename
|
||||||
|
* (SC-3): Group.name/ldapDn are updated to the AD state,
|
||||||
|
* groupsRenamed++. internalName is NEVER written here (D-04). A
|
||||||
|
* resulting P2002 (the new name collides with an existing local group)
|
||||||
|
* is caught, reported as an error line, and the group is left
|
||||||
|
* unchanged — the run continues with the remaining candidates.
|
||||||
|
* 4. No hit is a disappearance (SC-4/D-05): the default-marker handoff
|
||||||
|
* (reassignDefaultBeforeDelete) runs BEFORE the delete — this ordering
|
||||||
|
* is the actual correctness guarantee of D-06, not a style choice. A
|
||||||
|
* resulting P2025 (already gone, e.g. a concurrent manual delete) is
|
||||||
|
* swallowed without double-counting.
|
||||||
|
*
|
||||||
|
* A single group's search/DB failure never stops the run — the same
|
||||||
|
* per-group try/catch pattern as syncGroupMembershipsForTenant above.
|
||||||
|
*
|
||||||
|
* After the loop, if at least one deletion happened, ensureDefaultGroup()
|
||||||
|
* runs once (not per-deletion — it is idempotent and returns immediately
|
||||||
|
* once any group exists) to close the RESEARCH.md Pitfall 5 window: a
|
||||||
|
* tenant must never be left with zero groups until the next API restart.
|
||||||
|
*/
|
||||||
|
private async syncBoundGroupsForTenant(
|
||||||
|
client: Client,
|
||||||
|
config: LdapConfigData,
|
||||||
|
tenantId: string,
|
||||||
|
result: LdapSyncResult,
|
||||||
|
): Promise<void> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
|
const candidates: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ldapDn: string | null;
|
||||||
|
ldapObjectGuid: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
}[] = await tenantPrisma.group.findMany({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
OR: [{ ldapObjectGuid: { not: null } }, { ldapDn: { not: null } }],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ldapDn: true,
|
||||||
|
ldapObjectGuid: true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDns = this.parseBaseDns(config.baseDn);
|
||||||
|
let anyDeleted = false;
|
||||||
|
|
||||||
|
for (const group of candidates) {
|
||||||
|
try {
|
||||||
|
let ldapObjectGuid = group.ldapObjectGuid;
|
||||||
|
|
||||||
|
// 1. Legacy-binding backfill (D-07-Anschluss).
|
||||||
|
if (!ldapObjectGuid && group.ldapDn) {
|
||||||
|
const { searchEntries } = await client.search(group.ldapDn, {
|
||||||
|
filter: '(objectClass=group)',
|
||||||
|
attributes: ['cn', 'dn', 'objectGUID'],
|
||||||
|
explicitBufferAttributes: ['objectGUID'],
|
||||||
|
scope: 'base',
|
||||||
|
});
|
||||||
|
if (searchEntries.length === 0) {
|
||||||
|
result.errors.push(
|
||||||
|
`Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} laesst sich nicht mehr aufloesen`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const backfillRecord = searchEntries[0] as unknown as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const backfillGuid = backfillRecord['objectGUID'];
|
||||||
|
if (!Buffer.isBuffer(backfillGuid)) {
|
||||||
|
result.errors.push(
|
||||||
|
`Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} ohne lesbaren objectGUID`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ldapObjectGuid = backfillGuid.toString('hex');
|
||||||
|
await tenantPrisma.group.update({
|
||||||
|
where: { id: group.id },
|
||||||
|
data: { ldapObjectGuid },
|
||||||
|
});
|
||||||
|
result.groupsAdopted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Existence sweep — validate BEFORE any filter interpolation
|
||||||
|
// (T-16-01).
|
||||||
|
if (!ldapObjectGuid || !/^[0-9a-f]{32}$/.test(ldapObjectGuid)) {
|
||||||
|
result.errors.push(
|
||||||
|
`Gruppe ${group.name}: ungueltiger ldapObjectGuid-Wert`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const guidBuffer = Buffer.from(ldapObjectGuid, 'hex');
|
||||||
|
const filter = `(objectGUID=${LdapService.escapeLdapFilterBuffer(guidBuffer)})`;
|
||||||
|
|
||||||
|
let hit: Entry | null = null;
|
||||||
|
for (const baseDn of baseDns) {
|
||||||
|
const { searchEntries } = await client.search(baseDn, {
|
||||||
|
filter,
|
||||||
|
attributes: ['cn', 'dn'],
|
||||||
|
scope: 'sub',
|
||||||
|
});
|
||||||
|
if (searchEntries.length > 0) {
|
||||||
|
hit = searchEntries[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
// 3. Rename/DN reconciliation (SC-3).
|
||||||
|
const hitRecord = hit as unknown as Record<string, unknown>;
|
||||||
|
const rawName = hitRecord['cn'];
|
||||||
|
const name = Array.isArray(rawName)
|
||||||
|
? String(rawName[0])
|
||||||
|
: rawName
|
||||||
|
? String(rawName)
|
||||||
|
: hit.dn;
|
||||||
|
const dn = hit.dn;
|
||||||
|
|
||||||
|
if (name !== group.name || dn !== group.ldapDn) {
|
||||||
|
try {
|
||||||
|
await tenantPrisma.group.update({
|
||||||
|
where: { id: group.id },
|
||||||
|
data: { name, ldapDn: dn },
|
||||||
|
});
|
||||||
|
result.groupsRenamed++;
|
||||||
|
} catch (updateError: any) {
|
||||||
|
if (updateError?.code === 'P2002') {
|
||||||
|
result.errors.push(
|
||||||
|
`Gruppe ${group.name}: Umbenennung nach '${name}' kollidiert mit einer bestehenden Gruppe`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw updateError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 4. Disappearance (SC-4/D-05/D-06) — handoff BEFORE delete.
|
||||||
|
const movedDefault =
|
||||||
|
await this.groupsService.reassignDefaultBeforeDelete(
|
||||||
|
tenantId,
|
||||||
|
group.id,
|
||||||
|
);
|
||||||
|
if (movedDefault) {
|
||||||
|
result.defaultMarkerMoved++;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await tenantPrisma.group.delete({ where: { id: group.id } });
|
||||||
|
result.groupsDeleted++;
|
||||||
|
anyDeleted = true;
|
||||||
|
} catch (deleteError: any) {
|
||||||
|
if (deleteError?.code !== 'P2025') {
|
||||||
|
throw deleteError;
|
||||||
|
}
|
||||||
|
// Already gone (e.g. a concurrent manual delete) — not
|
||||||
|
// double-counted.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (groupError: unknown) {
|
||||||
|
const msg =
|
||||||
|
groupError instanceof Error
|
||||||
|
? groupError.message
|
||||||
|
: 'Unknown error reconciling group';
|
||||||
|
result.errors.push(`Gruppe ${group.name}: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyDeleted) {
|
||||||
|
await this.groupsService.ensureDefaultGroup(tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize LDAP search filter to prevent injection (T-02-16).
|
* Sanitize LDAP search filter to prevent injection (T-02-16).
|
||||||
* Escapes special characters per RFC 4515.
|
* Escapes special characters per RFC 4515.
|
||||||
|
|||||||
Reference in New Issue
Block a user