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:
@@ -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<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 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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
{config && (
|
||||
<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.",
|
||||
"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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user