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:
@@ -135,19 +135,22 @@ enum MembershipSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Group {
|
model Group {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
name String
|
name String
|
||||||
ldapDn String? // optionale AD-Bindung (D-05)
|
ldapDn String? // optionale AD-Bindung (D-05)
|
||||||
isDefault Boolean @default(false) // D-13 — genau eine pro Mandant, DB-erzwungen (Hand-SQL)
|
internalName String? // D-04: vom Sync nie berührt, überschreibt die Anzeige wenn gesetzt
|
||||||
createdAt DateTime @default(now())
|
ldapObjectGuid String? // D-04/SC-3: hex-kodierter objectGUID, rename-stabiler Sync-Match-Key (ldapDn bleibt Anzeige-/Debug-Feld)
|
||||||
updatedAt DateTime @updatedAt
|
isDefault Boolean @default(false) // D-13 — genau eine pro Mandant, DB-erzwungen (Hand-SQL)
|
||||||
memberships GroupMembership[]
|
createdAt DateTime @default(now())
|
||||||
grants ModuleGrant[]
|
updatedAt DateTime @updatedAt
|
||||||
|
memberships GroupMembership[]
|
||||||
|
grants ModuleGrant[]
|
||||||
|
|
||||||
@@unique([tenantId, name]) // Gruppennamen sind pro Mandant eindeutig
|
@@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, 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])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,3 +112,15 @@ export class ImportUsersDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
dns!: string[];
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
|||||||
import {
|
import {
|
||||||
CreateFieldMappingDto,
|
CreateFieldMappingDto,
|
||||||
CreateLdapConfigDto,
|
CreateLdapConfigDto,
|
||||||
|
ImportGroupsDto,
|
||||||
ImportUsersDto,
|
ImportUsersDto,
|
||||||
TestConnectionDto,
|
TestConnectionDto,
|
||||||
UpdateLdapConfigDto,
|
UpdateLdapConfigDto,
|
||||||
@@ -168,13 +169,54 @@ export class LdapController {
|
|||||||
throw new NotFoundException('No LDAP config found for this tenant');
|
throw new NotFoundException('No LDAP config found for this tenant');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.ldapService.listGroups({
|
return this.ldapService.listGroups(
|
||||||
serverUrl: config.serverUrl,
|
{
|
||||||
baseDn: config.baseDn,
|
serverUrl: config.serverUrl,
|
||||||
bindDn: config.bindDn,
|
baseDn: config.baseDn,
|
||||||
bindPassword: config.bindPassword,
|
bindDn: config.bindDn,
|
||||||
tlsRejectUnauthorized: config.tlsRejectUnauthorized,
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,12 +43,32 @@ interface LdapConfigData {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A discovered AD group or organizational unit, returned by listGroups()
|
* 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 {
|
export interface LdapDirectoryEntry {
|
||||||
dn: string;
|
dn: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'group' | 'ou';
|
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.
|
* Discover groups and organizational units under EVERY configured base DN.
|
||||||
* Used by the admin UI to build a selective import filter (groupFilterDns).
|
* Used by the admin UI both to build a selective import filter
|
||||||
* Read-only directory query using the service-account bind. Results from
|
* (groupFilterDns) and — filtered client-side to type: 'group' — to pick
|
||||||
* all base DNs are merged and deduped by entry dn.
|
* 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: {
|
async listGroups(
|
||||||
serverUrl: string;
|
config: {
|
||||||
baseDn: string;
|
serverUrl: string;
|
||||||
bindDn?: string | null;
|
baseDn: string;
|
||||||
bindPassword?: string | null;
|
bindDn?: string | null;
|
||||||
tlsRejectUnauthorized?: boolean | null;
|
bindPassword?: string | null;
|
||||||
}): Promise<LdapDirectoryEntry[]> {
|
tlsRejectUnauthorized?: boolean | null;
|
||||||
|
},
|
||||||
|
tenantId: string,
|
||||||
|
): Promise<LdapDirectoryEntry[]> {
|
||||||
const client = new Client(
|
const client = new Client(
|
||||||
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized),
|
||||||
);
|
);
|
||||||
@@ -235,7 +268,8 @@ export class LdapService {
|
|||||||
for (const baseDn of baseDns) {
|
for (const baseDn of baseDns) {
|
||||||
const { searchEntries } = await client.search(baseDn, {
|
const { searchEntries } = await client.search(baseDn, {
|
||||||
filter: '(|(objectClass=group)(objectClass=organizationalUnit))',
|
filter: '(|(objectClass=group)(objectClass=organizationalUnit))',
|
||||||
attributes: ['cn', 'ou', 'dn'],
|
attributes: ['cn', 'ou', 'dn', 'objectGUID'],
|
||||||
|
explicitBufferAttributes: ['objectGUID'],
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
});
|
});
|
||||||
for (const entry of searchEntries) {
|
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 dn = entry.dn;
|
||||||
const isOu = /^ou=/i.test(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)
|
const name = Array.isArray(rawName)
|
||||||
? String(rawName[0])
|
? String(rawName[0])
|
||||||
: rawName
|
: rawName
|
||||||
? String(rawName)
|
? String(rawName)
|
||||||
: dn;
|
: dn;
|
||||||
|
const guidValue = record['objectGUID'];
|
||||||
|
const guidHex =
|
||||||
|
!isOu && Buffer.isBuffer(guidValue)
|
||||||
|
? guidValue.toString('hex')
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dn,
|
dn,
|
||||||
name,
|
name,
|
||||||
type: isOu ? ('ou' as const) : ('group' as const),
|
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 {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await client.unbind();
|
await client.unbind();
|
||||||
@@ -546,6 +612,125 @@ export class LdapService {
|
|||||||
return result;
|
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.
|
* Sync users from LDAP directory for a specific tenant.
|
||||||
*
|
*
|
||||||
@@ -982,4 +1167,19 @@ export class LdapService {
|
|||||||
.replace(/\)/g, '\\29')
|
.replace(/\)/g, '\\29')
|
||||||
.replace(/\x00/g, '\\00');
|
.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('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface LdapDirectoryEntry {
|
|||||||
dn: string;
|
dn: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'group' | 'ou';
|
type: 'group' | 'ou';
|
||||||
|
alreadyImported?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LdapUserSearchResult {
|
interface LdapUserSearchResult {
|
||||||
@@ -51,6 +52,13 @@ interface UserImportResult {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GroupImportResult {
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
nameCollisions: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface SyncResult {
|
interface SyncResult {
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
@@ -117,6 +125,32 @@ export default function AdminLdapPage() {
|
|||||||
const [userImportResult, setUserImportResult] =
|
const [userImportResult, setUserImportResult] =
|
||||||
useState<UserImportResult | null>(null);
|
useState<UserImportResult | null>(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<string[]>([]);
|
||||||
|
const [importingGroups, setImportingGroups] = useState(false);
|
||||||
|
const [groupImportResult, setGroupImportResult] =
|
||||||
|
useState<GroupImportResult | null>(null);
|
||||||
|
const [groupImportError, setGroupImportError] = useState<string | null>(
|
||||||
|
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 filteredDiscovered = discovered?.filter((entry) => {
|
||||||
const q = discoverSearch.trim().toLowerCase();
|
const q = discoverSearch.trim().toLowerCase();
|
||||||
if (!q) return true;
|
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 () => {
|
const handleSaveExcludeList = async () => {
|
||||||
setSavingExclude(true);
|
setSavingExclude(true);
|
||||||
try {
|
try {
|
||||||
@@ -805,6 +893,140 @@ export default function AdminLdapPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Section 2.52: AD group import (SC-1/SC-2, D-01/D-02) */}
|
||||||
|
{config && (
|
||||||
|
<section className="rounded-lg border border-border p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{t('groupImport.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('groupImport.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDiscoverGroupsToImport}
|
||||||
|
disabled={discoveringGroupImport}
|
||||||
|
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{discoveringGroupImport
|
||||||
|
? tCommon('loading')
|
||||||
|
: t('groupImport.discover')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groupImportError && (
|
||||||
|
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{groupImportError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupImportCandidates && groupImportCandidates.length > 0 && (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={groupImportSearch}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
|
{filteredGroupImportCandidates &&
|
||||||
|
filteredGroupImportCandidates.length > 0 ? (
|
||||||
|
filteredGroupImportCandidates.map((entry) => (
|
||||||
|
<label
|
||||||
|
key={entry.dn}
|
||||||
|
className={`flex items-center gap-3 px-4 py-2 text-sm ${
|
||||||
|
entry.alreadyImported
|
||||||
|
? 'opacity-60'
|
||||||
|
: 'hover:bg-muted/30 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={entry.alreadyImported}
|
||||||
|
checked={selectedImportDns.includes(entry.dn)}
|
||||||
|
onChange={() => toggleImportDn(entry.dn)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground truncate">
|
||||||
|
{entry.dn}
|
||||||
|
</span>
|
||||||
|
{entry.alreadyImported && (
|
||||||
|
<span className="ml-auto shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{t('groupImport.alreadyImported')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{t('groupImport.noMatches')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupImportCandidates && groupImportCandidates.length === 0 && (
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{t('groupImport.noneFound')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupImportCandidates && groupImportCandidates.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleImportGroups}
|
||||||
|
disabled={importingGroups || selectedImportDns.length === 0}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{importingGroups
|
||||||
|
? tCommon('loading')
|
||||||
|
: `${t('groupImport.importSelected')} (${selectedImportDns.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupImportResult && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('groupImport.resultSummary', {
|
||||||
|
imported: groupImportResult.imported,
|
||||||
|
skipped: groupImportResult.skipped,
|
||||||
|
errors:
|
||||||
|
groupImportResult.errors.length +
|
||||||
|
groupImportResult.nameCollisions.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{groupImportResult.nameCollisions.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{groupImportResult.nameCollisions.map((name, i) => (
|
||||||
|
<p key={`collision-${i}`} className="text-xs text-destructive">
|
||||||
|
{t('groupImport.nameCollisionError', { name })}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groupImportResult.errors.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{groupImportResult.errors.map((err, i) => (
|
||||||
|
<p key={`error-${i}`} className="text-xs text-destructive">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{t('groupImport.membershipHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Section 2.55: Individual user search & import */}
|
{/* Section 2.55: Individual user search & import */}
|
||||||
{config && (
|
{config && (
|
||||||
<section className="rounded-lg border border-border p-6">
|
<section className="rounded-lg border border-border p-6">
|
||||||
|
|||||||
@@ -346,6 +346,21 @@
|
|||||||
"emptyMeansAll": "Keine Auswahl - alle Benutzer unter den Basis-DN(s) werden synchronisiert.",
|
"emptyMeansAll": "Keine Auswahl - alle Benutzer unter den Basis-DN(s) werden synchronisiert.",
|
||||||
"save": "Filter speichern"
|
"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": {
|
"userExclude": {
|
||||||
"title": "Benutzer ausschliessen (Denylist)",
|
"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.",
|
"description": "Einzelne Benutzernamen, die nie importiert werden - z. B. Dienstkonten wie administrator, krbtgt, guest oder ldap$. Wirkt zusaetzlich zum Gruppen-/OU-Filter.",
|
||||||
|
|||||||
@@ -346,6 +346,21 @@
|
|||||||
"emptyMeansAll": "No selection - all users under the base DN(s) are synced.",
|
"emptyMeansAll": "No selection - all users under the base DN(s) are synced.",
|
||||||
"save": "Save filter"
|
"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": {
|
"userExclude": {
|
||||||
"title": "Exclude users (denylist)",
|
"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.",
|
"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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user