feat(16-01): tracer — select and import AD groups end-to-end

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 <behavior> 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.
This commit is contained in:
2026-08-06 15:09:35 +02:00
parent c4a25511f3
commit 3523e43a13
8 changed files with 767 additions and 31 deletions
+214 -14
View File
@@ -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<LdapDirectoryEntry[]> {
async listGroups(
config: {
serverUrl: string;
baseDn: string;
bindDn?: string | null;
bindPassword?: string | null;
tlsRejectUnauthorized?: boolean | null;
},
tenantId: string,
): Promise<LdapDirectoryEntry[]> {
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<string, unknown>;
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<string>();
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<LdapGroupImportResult> {
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<string, unknown>;
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('');
}
}