From 3523e43a137e50886db56a8a1405e8e2edc49c7a Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 6 Aug 2026 15:09:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(16-01):=20tracer=20=E2=80=94=20select=20an?= =?UTF-8?q?d=20import=20AD=20groups=20end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 checkpoint resolved: approve-both, granted 2026-08-06 by the project owner (D-04 one-way schema extension: Group.internalName + Group.ldapObjectGuid, both nullable, one versioned migration). Adds the Phase 16 tracer slice through every layer: - Prisma schema: Group.internalName, Group.ldapObjectGuid, @@unique([tenantId, ldapObjectGuid]) (Prisma client regenerated; the versioned migration itself is Task 3, separately blocking). - LdapService: listGroups() now reads objectGUID via explicitBufferAttributes and flags alreadyImported per tenant; new importGroupsByDn() creates a Group per checked DN with name/ldapDn/ldapObjectGuid, reject-with-report on name collision (P2002 on name -> nameCollisions, P2002 on ldapObjectGuid -> skipped), never aborts the batch on one DN's error; new static escapeLdapFilterBuffer() for Plan 16-03's later existence sweep. - DTO/controller: ImportGroupsDto, POST /ldap/groups/import (ADMIN/SUPER_ADMIN), listGroups route now tenant-scoped. - Frontend: new "AD-Gruppen importieren" section in /admin/ldap, own discovery/import handlers with a visible error state (Owner decision 2026-08-06 — no silent catch{} for these two handlers), i18n keys in de.json/en.json. - Tests: 8 new cases covering the full list plus listGroups sort order and alreadyImported. Flagged assumption (RESEARCH.md A1/A2): objectGUID rename-stability and the binary filter syntax are unverified against a real AD — this plan only WRITES the GUID, Plan 16-03 reads it back live. --- apps/api/prisma/schema.prisma | 23 +- apps/api/src/ldap/dto/ldap-config.dto.ts | 12 + apps/api/src/ldap/ldap.controller.ts | 56 ++++- apps/api/src/ldap/ldap.service.spec.ts | 227 +++++++++++++++++ apps/api/src/ldap/ldap.service.ts | 228 ++++++++++++++++-- apps/web/src/app/(portal)/admin/ldap/page.tsx | 222 +++++++++++++++++ apps/web/src/messages/de.json | 15 ++ apps/web/src/messages/en.json | 15 ++ 8 files changed, 767 insertions(+), 31 deletions(-) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 42e2d3b..fe2be76 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -135,19 +135,22 @@ enum MembershipSource { } model Group { - id String @id @default(uuid()) - tenantId String - tenant Tenant @relation(fields: [tenantId], references: [id]) - name String - ldapDn String? // optionale AD-Bindung (D-05) - isDefault Boolean @default(false) // D-13 — genau eine pro Mandant, DB-erzwungen (Hand-SQL) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - memberships GroupMembership[] - grants ModuleGrant[] + id String @id @default(uuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + name String + ldapDn String? // optionale AD-Bindung (D-05) + internalName String? // D-04: vom Sync nie berührt, überschreibt die Anzeige wenn gesetzt + ldapObjectGuid String? // D-04/SC-3: hex-kodierter objectGUID, rename-stabiler Sync-Match-Key (ldapDn bleibt Anzeige-/Debug-Feld) + isDefault Boolean @default(false) // D-13 — genau eine pro Mandant, DB-erzwungen (Hand-SQL) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + memberships GroupMembership[] + grants ModuleGrant[] @@unique([tenantId, name]) // Gruppennamen sind pro Mandant eindeutig @@unique([tenantId, ldapDn]) // NULL ist in Postgres je Zeile distinct — mehrere ungebundene Gruppen sind erlaubt + @@unique([tenantId, ldapObjectGuid]) // gleiches NULL-ist-distinct-Muster wie @@unique([tenantId, ldapDn]) @@index([tenantId]) } diff --git a/apps/api/src/ldap/dto/ldap-config.dto.ts b/apps/api/src/ldap/dto/ldap-config.dto.ts index efdddb0..5a6a8a1 100644 --- a/apps/api/src/ldap/dto/ldap-config.dto.ts +++ b/apps/api/src/ldap/dto/ldap-config.dto.ts @@ -112,3 +112,15 @@ export class ImportUsersDto { @IsString({ each: true }) dns!: string[]; } + +/** + * DTO for POST /ldap/groups/import — the DNs of AD groups to import as + * Tessera groups (SC-1/SC-2). ArrayNotEmpty rejects an empty selection with + * HTTP 400 — an admin can never trigger a zero-DN import request. + */ +export class ImportGroupsDto { + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + dns!: string[]; +} diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index a324825..558961b 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator'; import { CreateFieldMappingDto, CreateLdapConfigDto, + ImportGroupsDto, ImportUsersDto, TestConnectionDto, UpdateLdapConfigDto, @@ -168,13 +169,54 @@ export class LdapController { throw new NotFoundException('No LDAP config found for this tenant'); } - return this.ldapService.listGroups({ - serverUrl: config.serverUrl, - baseDn: config.baseDn, - bindDn: config.bindDn, - bindPassword: config.bindPassword, - tlsRejectUnauthorized: config.tlsRejectUnauthorized, - }); + return this.ldapService.listGroups( + { + serverUrl: config.serverUrl, + baseDn: config.baseDn, + bindDn: config.bindDn, + bindPassword: config.bindPassword, + tlsRejectUnauthorized: config.tlsRejectUnauthorized, + }, + tenantId, + ); + } + + /** + * POST /ldap/groups/import - Import specific AD groups by DN (from the + * group discovery list, filtered to type: 'group') as Tessera groups + * (SC-1/SC-2, D-01/D-02). Static route, placed before any future dynamic + * `:id`-style route on this controller (project route-order convention). + */ + @Post('groups/import') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async importGroups(@Req() req: any, @Body() dto: ImportGroupsDto) { + const tenantId = req.tenantId; + if (!tenantId) { + throw new BadRequestException('No tenant context'); + } + + const config = await this.ldapConfigService.getConfig(tenantId); + if (!config) { + throw new NotFoundException('No LDAP config found for this tenant'); + } + + return this.ldapService.importGroupsByDn( + { + id: config.id, + tenantId: config.tenantId, + serverUrl: config.serverUrl, + baseDn: config.baseDn, + bindDn: config.bindDn, + bindPassword: config.bindPassword, + tlsRejectUnauthorized: config.tlsRejectUnauthorized, + searchFilter: config.searchFilter, + groupFilterDns: config.groupFilterDns, + userExcludeList: config.userExcludeList, + fieldMappings: config.fieldMappings, + }, + tenantId, + dto.dns, + ); } /** diff --git a/apps/api/src/ldap/ldap.service.spec.ts b/apps/api/src/ldap/ldap.service.spec.ts index 389160f..7bfb4f4 100644 --- a/apps/api/src/ldap/ldap.service.spec.ts +++ b/apps/api/src/ldap/ldap.service.spec.ts @@ -874,3 +874,230 @@ describe('LdapService.syncUsersForTenant — AD-bound group membership sync (D-1 ); }); }); + +describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => { + let service: LdapService; + let prisma: any; + let userService: any; + + 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. + const guidBuffer = Buffer.from('0123456789abcdef0123456789abcde', 'hex'); + const guidHex = guidBuffer.toString('hex'); + + beforeEach(() => { + vi.clearAllMocks(); + mockBind.mockResolvedValue(undefined); + mockUnbind.mockResolvedValue(undefined); + prisma = { + group: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({}), + }, + }; + userService = { create: vi.fn().mockResolvedValue({}) }; + service = new LdapService(prisma, userService); + }); + + it('importGroupsByDn creates a Group with name/ldapDn/ldapObjectGuid and counts imported', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { + dn: 'cn=Sales,ou=groups,dc=example,dc=com', + cn: 'Sales', + objectGUID: guidBuffer, + }, + ], + }); + + const res = await service.importGroupsByDn(cfg as any, 't1', [ + 'cn=Sales,ou=groups,dc=example,dc=com', + ]); + + expect(res.imported).toBe(1); + expect(res.skipped).toBe(0); + expect(res.errors).toEqual([]); + expect(res.nameCollisions).toEqual([]); + expect(prisma.group.create).toHaveBeenCalledWith({ + data: { + tenantId: 't1', + name: 'Sales', + ldapDn: 'cn=Sales,ou=groups,dc=example,dc=com', + ldapObjectGuid: guidHex, + }, + }); + }); + + it('importGroupsByDn skips a DN whose ldapObjectGuid already exists for this tenant (no duplicate row)', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { + dn: 'cn=Sales,ou=groups,dc=example,dc=com', + cn: 'Sales', + objectGUID: guidBuffer, + }, + ], + }); + prisma.group.findFirst.mockResolvedValue({ id: 'g1', ldapObjectGuid: guidHex }); + + const res = await service.importGroupsByDn(cfg as any, 't1', [ + 'cn=Sales,ou=groups,dc=example,dc=com', + ]); + + expect(res.imported).toBe(0); + expect(res.skipped).toBe(1); + expect(prisma.group.create).not.toHaveBeenCalled(); + }); + + it('importGroupsByDn records a DN with no AD hit as an error line, not a Group row', async () => { + mockSearch.mockResolvedValue({ searchEntries: [] }); + + const res = await service.importGroupsByDn(cfg as any, 't1', [ + 'cn=Ghost,ou=groups,dc=example,dc=com', + ]); + + expect(res.imported).toBe(0); + expect(res.errors).toEqual([ + 'cn=Ghost,ou=groups,dc=example,dc=com: not found', + ]); + expect(prisma.group.create).not.toHaveBeenCalled(); + }); + + it('importGroupsByDn reports a name collision (P2002 on tenantId,name) without aborting the remaining DNs', async () => { + mockSearch.mockImplementation((dn: string) => { + if (dn === 'cn=Collide,ou=groups,dc=example,dc=com') { + return Promise.resolve({ + searchEntries: [ + { dn, cn: 'Collide', objectGUID: guidBuffer }, + ], + }); + } + return Promise.resolve({ + searchEntries: [ + { + dn, + cn: 'Second', + objectGUID: Buffer.from( + 'ffffffffffffffffffffffffffffffff', + 'hex', + ), + }, + ], + }); + }); + const nameCollisionError = Object.assign(new Error('Unique constraint'), { + code: 'P2002', + meta: { target: ['tenantId', 'name'] }, + }); + prisma.group.create + .mockRejectedValueOnce(nameCollisionError) + .mockResolvedValueOnce({}); + + const res = await service.importGroupsByDn(cfg as any, 't1', [ + 'cn=Collide,ou=groups,dc=example,dc=com', + 'cn=Second,ou=groups,dc=example,dc=com', + ]); + + expect(res.nameCollisions).toEqual(['Collide']); + expect(res.imported).toBe(1); + expect(res.errors).toEqual([]); + expect(prisma.group.create).toHaveBeenCalledTimes(2); + }); + + it('importGroupsByDn treats a P2002 on (tenantId, ldapObjectGuid) like skipped, not an error', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { + dn: 'cn=Sales,ou=groups,dc=example,dc=com', + cn: 'Sales', + objectGUID: guidBuffer, + }, + ], + }); + const raceLossError = Object.assign(new Error('Unique constraint'), { + code: 'P2002', + meta: { target: ['tenantId', 'ldapObjectGuid'] }, + }); + prisma.group.create.mockRejectedValue(raceLossError); + + const res = await service.importGroupsByDn(cfg as any, 't1', [ + 'cn=Sales,ou=groups,dc=example,dc=com', + ]); + + expect(res.skipped).toBe(1); + expect(res.imported).toBe(0); + expect(res.nameCollisions).toEqual([]); + expect(res.errors).toEqual([]); + }); + + it('importGroupsByDn records an entry with no readable objectGUID buffer as an error, never a mis-stringified value', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { + dn: 'cn=NoBuffer,ou=groups,dc=example,dc=com', + cn: 'NoBuffer', + // Missing/absent objectGUID, exactly as ldapts represents it. + objectGUID: [], + }, + ], + }); + + const res = await service.importGroupsByDn(cfg as any, 't1', [ + 'cn=NoBuffer,ou=groups,dc=example,dc=com', + ]); + + expect(res.imported).toBe(0); + expect(res.errors).toEqual([ + 'cn=NoBuffer,ou=groups,dc=example,dc=com: objectGUID not readable', + ]); + expect(prisma.group.create).not.toHaveBeenCalled(); + }); + + it('listGroups marks entries as alreadyImported by matching hex ldapObjectGuid for this tenant', async () => { + const otherGuid = Buffer.from('11'.repeat(16), 'hex'); + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=Sales,ou=groups,dc=example,dc=com', cn: 'Sales', objectGUID: guidBuffer }, + { dn: 'cn=IT,ou=groups,dc=example,dc=com', cn: 'IT', objectGUID: otherGuid }, + { dn: 'ou=groups,dc=example,dc=com', ou: 'groups' }, + ], + }); + prisma.group.findMany.mockResolvedValue([{ ldapObjectGuid: guidHex }]); + + const res = await service.listGroups(cfg as any, 't1'); + + expect(res.find((e) => e.name === 'Sales')?.alreadyImported).toBe(true); + expect(res.find((e) => e.name === 'IT')?.alreadyImported).toBe(false); + expect(res.find((e) => e.type === 'ou')?.alreadyImported).toBe(false); + }); + + it('listGroups sorts results by name (localeCompare), then dn on a tie', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=Zebra,ou=groups,dc=example,dc=com', cn: 'Zebra', objectGUID: [] }, + { dn: 'cn=Apple,ou=b,dc=example,dc=com', cn: 'Apple', objectGUID: [] }, + { dn: 'cn=Apple,ou=a,dc=example,dc=com', cn: 'Apple', objectGUID: [] }, + ], + }); + + const res = await service.listGroups(cfg as any, 't1'); + + expect(res.map((e) => e.dn)).toEqual([ + 'cn=Apple,ou=a,dc=example,dc=com', + 'cn=Apple,ou=b,dc=example,dc=com', + 'cn=Zebra,ou=groups,dc=example,dc=com', + ]); + }); +}); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 2eba74a..6171c43 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -43,12 +43,32 @@ interface LdapConfigData { /** * A discovered AD group or organizational unit, returned by listGroups() - * for the admin to pick from when building a selective import filter. + * for the admin to pick from when building a selective import filter (D-18) + * or, filtered to type: 'group', when picking AD groups to import as + * Tessera groups (SC-1/SC-2, D-01/D-02). `alreadyImported` is only ever + * true for type: 'group' entries whose objectGUID already matches a + * Group.ldapObjectGuid of the requesting tenant — OUs are never importable + * and always report false. */ export interface LdapDirectoryEntry { dn: string; name: string; type: 'group' | 'ou'; + alreadyImported?: boolean; +} + +/** + * Result of importGroupsByDn() — a manual, selective AD-group-to-Tessera-group + * import (SC-1/SC-2, D-01/D-02). Name collisions are reported separately from + * generic errors so the frontend can translate the collision message + * (admin.ldap.groupImport.nameCollisionError) instead of rendering a raw + * German backend string. + */ +export interface LdapGroupImportResult { + imported: number; + skipped: number; + nameCollisions: string[]; + errors: string[]; } /** @@ -211,17 +231,30 @@ export class LdapService { /** * Discover groups and organizational units under EVERY configured base DN. - * Used by the admin UI to build a selective import filter (groupFilterDns). - * Read-only directory query using the service-account bind. Results from - * all base DNs are merged and deduped by entry dn. + * Used by the admin UI both to build a selective import filter + * (groupFilterDns) and — filtered client-side to type: 'group' — to pick + * AD groups to import as Tessera groups (D-01: one shared discovery + * endpoint, no second one). Read-only directory query using the + * service-account bind. Results from all base DNs are merged and deduped + * by entry dn. + * + * objectGUID is requested via explicitBufferAttributes so ldapts returns + * it as a Buffer instead of attempting a lossy UTF-8 decode of the raw + * 16-byte value (Pitfall 2, RESEARCH.md). It is used ONLY to compute + * alreadyImported for group entries against this tenant's + * Group.ldapObjectGuid rows — the hex value itself is never returned to + * the client (T-16-04, information disclosure). */ - async listGroups(config: { - serverUrl: string; - baseDn: string; - bindDn?: string | null; - bindPassword?: string | null; - tlsRejectUnauthorized?: boolean | null; - }): Promise { + async listGroups( + config: { + serverUrl: string; + baseDn: string; + bindDn?: string | null; + bindPassword?: string | null; + tlsRejectUnauthorized?: boolean | null; + }, + tenantId: string, + ): Promise { const client = new Client( this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized), ); @@ -235,7 +268,8 @@ export class LdapService { for (const baseDn of baseDns) { const { searchEntries } = await client.search(baseDn, { filter: '(|(objectClass=group)(objectClass=organizationalUnit))', - attributes: ['cn', 'ou', 'dn'], + attributes: ['cn', 'ou', 'dn', 'objectGUID'], + explicitBufferAttributes: ['objectGUID'], scope: 'sub', }); for (const entry of searchEntries) { @@ -243,22 +277,54 @@ export class LdapService { } } - return Array.from(entriesByDn.values()).map((entry) => { + const mapped = Array.from(entriesByDn.values()).map((entry) => { const dn = entry.dn; const isOu = /^ou=/i.test(dn); - const rawName = isOu ? entry['ou'] : entry['cn']; + const record = entry as unknown as Record; + const rawName = isOu ? record['ou'] : record['cn']; const name = Array.isArray(rawName) ? String(rawName[0]) : rawName ? String(rawName) : dn; + const guidValue = record['objectGUID']; + const guidHex = + !isOu && Buffer.isBuffer(guidValue) + ? guidValue.toString('hex') + : null; return { dn, name, type: isOu ? ('ou' as const) : ('group' as const), + guidHex, }; }); + + const guidHexes = mapped + .map((e) => e.guidHex) + .filter((h): h is string => !!h); + let importedSet = new Set(); + if (guidHexes.length > 0) { + const existing = await this.prisma.group.findMany({ + where: { tenantId, ldapObjectGuid: { in: guidHexes } }, + select: { ldapObjectGuid: true }, + }); + importedSet = new Set( + existing + .map((g: { ldapObjectGuid: string | null }) => g.ldapObjectGuid) + .filter((g: string | null): g is string => !!g), + ); + } + + return mapped + .map(({ guidHex, ...rest }) => ({ + ...rest, + alreadyImported: !!guidHex && importedSet.has(guidHex), + })) + .sort( + (a, b) => a.name.localeCompare(b.name) || a.dn.localeCompare(b.dn), + ); } finally { try { await client.unbind(); @@ -546,6 +612,125 @@ export class LdapService { return result; } + /** + * Import specific AD groups by DN (from listGroups results, filtered to + * type: 'group') as Tessera groups (SC-1/SC-2, D-01/D-02). Every DN is a + * base-scoped lookup — the admin never bulk-imports an OU or a search + * result, only the exact groups they checked. objectGUID is read via + * explicitBufferAttributes (same Pitfall-2 requirement as listGroups) and + * hex-encoded into Group.ldapObjectGuid, the rename-stable identity key + * Plan 16-03's reconciliation depends on. `GroupsService.create()` is + * deliberately NOT used here: its ConflictException is built for a single + * interactive HTTP request and would abort the whole batch on the first + * name collision (Pitfall 4, RESEARCH.md) — this loop instead collects a + * per-DN outcome and never stops on one group's error. + */ + async importGroupsByDn( + config: LdapConfigData, + tenantId: string, + dns: string[], + ): Promise { + const result: LdapGroupImportResult = { + imported: 0, + skipped: 0, + nameCollisions: [], + errors: [], + }; + + const client = new Client( + this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized), + ); + // Mandantengescopter Schreibpfad (T-16-02): app.current_tenant wird vor + // jedem group.create() gesetzt, RLS ist das zweite Netz. + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + + try { + await this.bind(client, config.bindDn, config.bindPassword); + + for (const dn of dns) { + try { + // Base-scoped lookup of exactly this DN — the admin-selected DN is + // the search BASE, never interpolated into a filter (T-16-01). + const { searchEntries } = await client.search(dn, { + filter: '(objectClass=group)', + attributes: ['cn', 'dn', 'objectGUID'], + explicitBufferAttributes: ['objectGUID'], + scope: 'base', + }); + if (searchEntries.length === 0) { + result.errors.push(`${dn}: not found`); + continue; + } + + const entry = searchEntries[0]; + const record = entry as unknown as Record; + const guidValue = record['objectGUID']; + if (!Buffer.isBuffer(guidValue)) { + result.errors.push(`${dn}: objectGUID not readable`); + continue; + } + const ldapObjectGuid = guidValue.toString('hex'); + + const rawName = record['cn']; + const name = Array.isArray(rawName) + ? String(rawName[0]) + : rawName + ? String(rawName) + : dn; + + // Idempotency: a second import of the same AD group is a skip, not + // a duplicate row. + const existingByGuid = await this.prisma.group.findFirst({ + where: { tenantId, ldapObjectGuid }, + }); + if (existingByGuid) { + result.skipped++; + continue; + } + + try { + await tenantPrisma.group.create({ + data: { tenantId, name, ldapDn: entry.dn, ldapObjectGuid }, + }); + result.imported++; + } catch (createError: any) { + if (createError?.code === 'P2002') { + const target = createError?.meta?.target; + const targetsGuid = Array.isArray(target) + ? target.includes('ldapObjectGuid') + : String(target ?? '').includes('ldapObjectGuid'); + if (targetsGuid) { + // Lost a race against a concurrent import of the same AD + // group — treat identically to the pre-check skip above. + result.skipped++; + } else { + // @@unique([tenantId, name]) violation: reject-with-report, + // never abort the remaining DNs (Pitfall 4). + result.nameCollisions.push(name); + } + } else { + throw createError; + } + } + } catch (entryError: unknown) { + const msg = + entryError instanceof Error + ? entryError.message + : 'Unknown error importing group'; + result.errors.push(`${dn}: ${msg}`); + } + } + } finally { + try { + await client.unbind(); + } catch { + // Ignore unbind errors + } + } + + return result; + } + /** * Sync users from LDAP directory for a specific tenant. * @@ -982,4 +1167,19 @@ export class LdapService { .replace(/\)/g, '\\29') .replace(/\x00/g, '\\00'); } + + /** + * Escape a binary value (e.g. a stored objectGUID) for use in an LDAP + * search filter per RFC 4515 — a byte-wise `\XX` hex escape, distinct from + * escapeLdapFilterValue() which escapes a STRING value. Not yet called + * anywhere in this plan (Plan 16-01 only WRITES ldapObjectGuid); Plan + * 16-03's existence sweep is the first caller, reading it back via a + * binary (objectGUID=...) filter. [ASSUMED — RFC 4515-Praxis, nicht gegen + * ein echtes AD verifiziert, siehe RESEARCH.md Pattern 3/A2.] + */ + static escapeLdapFilterBuffer(buf: Buffer): string { + return Array.from(buf) + .map((b) => '\\' + b.toString(16).padStart(2, '0')) + .join(''); + } } diff --git a/apps/web/src/app/(portal)/admin/ldap/page.tsx b/apps/web/src/app/(portal)/admin/ldap/page.tsx index bfe118c..3c30021 100644 --- a/apps/web/src/app/(portal)/admin/ldap/page.tsx +++ b/apps/web/src/app/(portal)/admin/ldap/page.tsx @@ -34,6 +34,7 @@ interface LdapDirectoryEntry { dn: string; name: string; type: 'group' | 'ou'; + alreadyImported?: boolean; } interface LdapUserSearchResult { @@ -51,6 +52,13 @@ interface UserImportResult { errors: string[]; } +interface GroupImportResult { + imported: number; + skipped: number; + nameCollisions: string[]; + errors: string[]; +} + interface SyncResult { created: number; updated: number; @@ -117,6 +125,32 @@ export default function AdminLdapPage() { const [userImportResult, setUserImportResult] = useState(null); + // AD group import (SC-1/SC-2, D-01/D-02) — own state, own discovery call, + // independent of Section 2.5's groupFilterDns picker (different purpose). + const [groupImportCandidates, setGroupImportCandidates] = useState< + LdapDirectoryEntry[] | null + >(null); + const [groupImportSearch, setGroupImportSearch] = useState(''); + const [discoveringGroupImport, setDiscoveringGroupImport] = useState(false); + const [selectedImportDns, setSelectedImportDns] = useState([]); + const [importingGroups, setImportingGroups] = useState(false); + const [groupImportResult, setGroupImportResult] = + useState(null); + const [groupImportError, setGroupImportError] = useState( + null, + ); + + const filteredGroupImportCandidates = groupImportCandidates?.filter( + (entry) => { + const q = groupImportSearch.trim().toLowerCase(); + if (!q) return true; + return ( + entry.name.toLowerCase().includes(q) || + entry.dn.toLowerCase().includes(q) + ); + }, + ); + const filteredDiscovered = discovered?.filter((entry) => { const q = discoverSearch.trim().toLowerCase(); if (!q) return true; @@ -400,6 +434,60 @@ export default function AdminLdapPage() { } }; + const handleDiscoverGroupsToImport = async () => { + setDiscoveringGroupImport(true); + setGroupImportError(null); + try { + const res = await fetch(`${API_URL}/ldap/groups`, { + credentials: 'include', + }); + if (res.ok) { + const data: LdapDirectoryEntry[] = await res.json(); + // Only AD groups are importable — OUs are not selectable here. + setGroupImportCandidates(data.filter((entry) => entry.type === 'group')); + } else { + setGroupImportError(t('groupImport.discoverError')); + } + } catch { + setGroupImportError(t('groupImport.discoverError')); + } finally { + setDiscoveringGroupImport(false); + } + }; + + const toggleImportDn = (dn: string) => { + setSelectedImportDns((prev) => + prev.includes(dn) ? prev.filter((d) => d !== dn) : [...prev, dn], + ); + }; + + const handleImportGroups = async () => { + if (selectedImportDns.length === 0) return; + setImportingGroups(true); + setGroupImportError(null); + try { + const res = await fetch(`${API_URL}/ldap/groups/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ dns: selectedImportDns }), + }); + if (res.ok) { + const data: GroupImportResult = await res.json(); + setGroupImportResult(data); + setSelectedImportDns([]); + // Re-run discovery so alreadyImported flags refresh immediately. + await handleDiscoverGroupsToImport(); + } else { + setGroupImportError(t('groupImport.importError')); + } + } catch { + setGroupImportError(t('groupImport.importError')); + } finally { + setImportingGroups(false); + } + }; + const handleSaveExcludeList = async () => { setSavingExclude(true); try { @@ -805,6 +893,140 @@ export default function AdminLdapPage() { )} + {/* Section 2.52: AD group import (SC-1/SC-2, D-01/D-02) */} + {config && ( +
+

+ {t('groupImport.title')} +

+

+ {t('groupImport.description')} +

+ +
+ +
+ + {groupImportError && ( +
+ {groupImportError} +
+ )} + + {groupImportCandidates && groupImportCandidates.length > 0 && ( +
+ setGroupImportSearch(e.target.value)} + placeholder={t('groupImport.searchPlaceholder')} + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm" + /> +
+ {filteredGroupImportCandidates && + filteredGroupImportCandidates.length > 0 ? ( + filteredGroupImportCandidates.map((entry) => ( + + )) + ) : ( +

+ {t('groupImport.noMatches')} +

+ )} +
+
+ )} + + {groupImportCandidates && groupImportCandidates.length === 0 && ( +

+ {t('groupImport.noneFound')} +

+ )} + + {groupImportCandidates && groupImportCandidates.length > 0 && ( + + )} + + {groupImportResult && ( +
+

+ {t('groupImport.resultSummary', { + imported: groupImportResult.imported, + skipped: groupImportResult.skipped, + errors: + groupImportResult.errors.length + + groupImportResult.nameCollisions.length, + })} +

+ {groupImportResult.nameCollisions.length > 0 && ( +
+ {groupImportResult.nameCollisions.map((name, i) => ( +

+ {t('groupImport.nameCollisionError', { name })} +

+ ))} +
+ )} + {groupImportResult.errors.length > 0 && ( +
+ {groupImportResult.errors.map((err, i) => ( +

+ {err} +

+ ))} +
+ )} +

+ {t('groupImport.membershipHint')} +

+
+ )} +
+ )} + {/* Section 2.55: Individual user search & import */} {config && (
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 661ac00..ffa292b 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -346,6 +346,21 @@ "emptyMeansAll": "Keine Auswahl - alle Benutzer unter den Basis-DN(s) werden synchronisiert.", "save": "Filter speichern" }, + "groupImport": { + "title": "AD-Gruppen importieren", + "description": "Ausgewählte AD-Gruppen werden als Tessera-Gruppen angelegt und danach bei jeder Synchronisation automatisch nachgeführt — Name, Mitgliedschaft und Löschung im AD ziehen nach.", + "discover": "AD-Gruppen suchen", + "searchPlaceholder": "AD-Gruppen durchsuchen...", + "noneFound": "Keine AD-Gruppen gefunden.", + "noMatches": "Keine Treffer für diese Suche.", + "alreadyImported": "Bereits importiert", + "importSelected": "Ausgewählte importieren", + "resultSummary": "{imported} importiert, {skipped} übersprungen{errors, plural, =0 {} other {, # Fehler}}", + "membershipHint": "Mitgliedschaften werden beim nächsten Sync-Lauf automatisch befüllt (manuell oder nach Intervall).", + "nameCollisionError": "Gruppe „{name}\" konnte nicht importiert werden: Der Name ist bereits vergeben. Benenne die bestehende lokale Gruppe um oder vergib ihr einen internen Namen.", + "discoverError": "AD-Gruppen konnten nicht abgerufen werden. Prüfe die LDAP-Verbindung und versuche es erneut.", + "importError": "Der Import konnte nicht ausgeführt werden. Es wurde keine Gruppe angelegt." + }, "userExclude": { "title": "Benutzer ausschliessen (Denylist)", "description": "Einzelne Benutzernamen, die nie importiert werden - z. B. Dienstkonten wie administrator, krbtgt, guest oder ldap$. Wirkt zusaetzlich zum Gruppen-/OU-Filter.", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index e01702a..792a52b 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -346,6 +346,21 @@ "emptyMeansAll": "No selection - all users under the base DN(s) are synced.", "save": "Save filter" }, + "groupImport": { + "title": "Import AD groups", + "description": "Selected AD groups are created as Tessera groups and kept in sync with every subsequent run — name, membership and deletion in AD carry over automatically.", + "discover": "Search AD groups", + "searchPlaceholder": "Search AD groups...", + "noneFound": "No AD groups found.", + "noMatches": "No matches for this search.", + "alreadyImported": "Already imported", + "importSelected": "Import selected", + "resultSummary": "{imported} imported, {skipped} skipped{errors, plural, =0 {} other {, # errors}}", + "membershipHint": "Memberships are filled in automatically by the next sync run (manual or scheduled).", + "nameCollisionError": "Group \"{name}\" could not be imported: the name is already taken. Rename the existing local group or give it an internal name.", + "discoverError": "AD groups could not be retrieved. Check the LDAP connection and try again.", + "importError": "The import could not be executed. No group was created." + }, "userExclude": { "title": "Exclude users (denylist)", "description": "Individual usernames that are never imported - e.g. service accounts like administrator, krbtgt, guest or ldap$. Applies on top of the group/OU filter.",