feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
Vendored
+161
@@ -0,0 +1,161 @@
|
||||
import { Attribute, Change, Client } from "ldapts";
|
||||
|
||||
/**
|
||||
* Zugang zum LDAP-Testcontainer und das, was im Fixture steht (#016).
|
||||
*
|
||||
* Die Werte stehen hier und nicht in den Tests, weil zwei Testdateien gegen
|
||||
* denselben Container laufen. Die Erwartungen (GUID-Zeichenketten,
|
||||
* Kontonamen) gehoeren mit dazu: sie beschreiben das Fixture und nicht die
|
||||
* Regeln — die stehen in den Tests.
|
||||
*
|
||||
* Der Container wird mit
|
||||
* `docker compose -f docker-compose.test.yml up -d ldap` gestartet.
|
||||
*/
|
||||
|
||||
export const LDAP_URL = "ldap://127.0.0.1:1389";
|
||||
export const LDAP_BASIS_DN = "dc=firma,dc=test";
|
||||
export const LDAP_BENUTZER_DN = "ou=Benutzer,dc=firma,dc=test";
|
||||
export const LDAP_BIND_DN = "cn=admin,dc=firma,dc=test";
|
||||
export const LDAP_BIND_PASSWORT = "ldap-test-geheim";
|
||||
|
||||
/**
|
||||
* Die GUID-Zeichenketten der Fixture-Personen.
|
||||
*
|
||||
* Ausgerechnet mit Pythons `uuid.UUID(bytes_le=…)` aus den Bytes in
|
||||
* `daten/50-personen.ldif` — eine unabhaengige Umsetzung derselben
|
||||
* gemischt-endianen Reihenfolge, damit hier nicht die Erwartung aus derselben
|
||||
* Quelle stammt wie das Ergebnis.
|
||||
*/
|
||||
export const GUID = {
|
||||
berger: "1d3e409c-b27a-4bc9-b1e6-f0a2c3d4e5f6",
|
||||
huber: "5f074310-3c35-4a1d-836f-a50f515e7a35",
|
||||
weiss: "04030201-0605-0807-090a-0b0c0d0e0f10",
|
||||
/** „Alte Kraft“: im Verzeichnis vorhanden, aber deaktiviert. */
|
||||
alteKraft: "ccddeeff-aabb-8899-7766-554433221100",
|
||||
} as const;
|
||||
|
||||
/** So viele Konten liefert der Standardfilter: drei aktive plus das ohne objectGUID. */
|
||||
export const FIXTURE_GELESEN = 4;
|
||||
/** Davon zugeordnet, also mit stabiler Kennung. */
|
||||
export const FIXTURE_PERSONEN = 3;
|
||||
|
||||
export type ContainerStand = { readonly da: true } | { readonly da: false; readonly grund: string };
|
||||
|
||||
/**
|
||||
* Laeuft der Container – und stehen die Fixture-Personen wirklich darin?
|
||||
*
|
||||
* Geprueft wird mit einem echten Bind und einer echten Suche, nicht mit einem
|
||||
* offenen Port: ein gestarteter, aber noch nicht bestueckter Container waere
|
||||
* sonst „da“, und die Tests liefen gegen ein leeres Verzeichnis. Ein stilles
|
||||
* Ueberspringen sieht genauso gruen aus wie ein bestandener Lauf, deshalb sagt
|
||||
* diese Funktion laut, was fehlt – und in CI bricht sie ab.
|
||||
*/
|
||||
export async function ldapContainerStand(): Promise<ContainerStand> {
|
||||
const client = new Client({ url: LDAP_URL, connectTimeout: 2000, timeout: 5000 });
|
||||
try {
|
||||
await client.bind(LDAP_BIND_DN, LDAP_BIND_PASSWORT);
|
||||
const { searchEntries } = await client.search(LDAP_BENUTZER_DN, {
|
||||
scope: "sub",
|
||||
filter: "(sAMAccountName=mhuber)",
|
||||
attributes: ["dn"],
|
||||
});
|
||||
if (searchEntries.length === 0) {
|
||||
return { da: false, grund: "Der Container laeuft, aber das Fixture „mhuber“ fehlt darin." };
|
||||
}
|
||||
return { da: true };
|
||||
} catch (fehler) {
|
||||
return { da: false, grund: fehler instanceof Error ? fehler.message : String(fehler) };
|
||||
} finally {
|
||||
await client.unbind().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Stand samt Hinweis auf der Konsole. In CI ein Abbruch: `pnpm test` ist das
|
||||
* Tor vor jedem Commit, und dort darf der Abgleich nicht ungeprueft bleiben.
|
||||
*/
|
||||
export async function ldapVerfuegbar(): Promise<boolean> {
|
||||
const stand = await ldapContainerStand();
|
||||
if (stand.da) return true;
|
||||
const hinweis =
|
||||
`\n LDAP-Testcontainer nicht erreichbar (${stand.grund}).` +
|
||||
"\n Die Verzeichnis- und Abgleichtests gegen das Verzeichnis werden uebersprungen." +
|
||||
"\n Starten mit: docker compose -f docker-compose.test.yml up -d ldap\n";
|
||||
if (process.env["CI"]) throw new Error(hinweis);
|
||||
// Direkt auf stderr und nicht ueber `console`: Vitest 5 fasst die
|
||||
// Konsolenausgabe einer Testdatei ab und zeigt sie fuer den **Modulrumpf**
|
||||
// nicht an — nachgemessen. Ein `console.warn` hier waere also genau das stille
|
||||
// Ueberspringen, das dieser Hinweis verhindern soll.
|
||||
process.stderr.write(hinweis);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Ein Client als Verzeichnisverwalter, fuer Tests, die das Fixture veraendern. */
|
||||
export async function alsVerwalter<T>(was: (client: Client) => Promise<T>): Promise<T> {
|
||||
const client = new Client({ url: LDAP_URL, connectTimeout: 2000, timeout: 5000 });
|
||||
try {
|
||||
await client.bind(LDAP_BIND_DN, LDAP_BIND_PASSWORT);
|
||||
return await was(client);
|
||||
} finally {
|
||||
await client.unbind().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine zusaetzliche Person an und gibt den Weg zurueck, sie wieder zu
|
||||
* entfernen.
|
||||
*
|
||||
* Das braucht der Test „ein entferntes Konto wird aktiv = false“: er muss ein
|
||||
* Konto wirklich loeschen. Die Fixture-Dateien bleiben dabei unberuehrt, und der
|
||||
* Container ist nach dem Test wieder wie vorher — sonst haengt das Ergebnis des
|
||||
* naechsten Tests an der Reihenfolge.
|
||||
*/
|
||||
export async function personAnlegen(eintrag: {
|
||||
readonly cn: string;
|
||||
readonly sn: string;
|
||||
readonly givenName: string;
|
||||
readonly sam: string;
|
||||
readonly guidBase64: string;
|
||||
readonly userAccountControl?: number;
|
||||
}): Promise<string> {
|
||||
const dn = `cn=${eintrag.cn},ou=Vertrieb,${LDAP_BENUTZER_DN}`;
|
||||
// Als `Attribute[]`, nicht als Objekt: die Objektform von `Client.add` nimmt
|
||||
// nur Zeichenketten, und objectGUID ist ein Puffer.
|
||||
const werte: Attribute[] = [
|
||||
new Attribute({ type: "objectClass", values: ["inetOrgPerson", "user"] }),
|
||||
new Attribute({ type: "cn", values: [eintrag.cn] }),
|
||||
new Attribute({ type: "sn", values: [eintrag.sn] }),
|
||||
new Attribute({ type: "givenName", values: [eintrag.givenName] }),
|
||||
new Attribute({ type: "displayName", values: [`${eintrag.sn}, ${eintrag.givenName}`] }),
|
||||
new Attribute({ type: "mail", values: [`${eintrag.sam}@firma.test`] }),
|
||||
new Attribute({ type: "userPrincipalName", values: [`${eintrag.sam}@firma.test`] }),
|
||||
new Attribute({ type: "sAMAccountName", values: [eintrag.sam] }),
|
||||
new Attribute({ type: "department", values: ["Vertrieb"] }),
|
||||
new Attribute({ type: "objectCategory", values: ["person"] }),
|
||||
new Attribute({ type: "userAccountControl", values: [String(eintrag.userAccountControl ?? 512)] }),
|
||||
new Attribute({ type: "objectGUID", values: [Buffer.from(eintrag.guidBase64, "base64")] }),
|
||||
];
|
||||
await alsVerwalter(async (client) => {
|
||||
await client.add(dn, werte);
|
||||
});
|
||||
return dn;
|
||||
}
|
||||
|
||||
export async function personLoeschen(dn: string): Promise<void> {
|
||||
await alsVerwalter(async (client) => {
|
||||
await client.del(dn);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ein Attribut aendern, um den zweiten Abgleich etwas zu tun zu geben. */
|
||||
export async function attributSetzen(dn: string, attribut: string, wert: string): Promise<void> {
|
||||
await alsVerwalter(async (client) => {
|
||||
await client.modify(
|
||||
dn,
|
||||
new Change({
|
||||
operation: "replace",
|
||||
modification: new Attribute({ type: attribut, values: [wert] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Fixture-Personen fuer den Verzeichnis-Abgleich (#016).
|
||||
#
|
||||
# Bewusst klein, aber mit jedem Fall, den der Abgleich unterscheiden muss:
|
||||
#
|
||||
# Maria Huber vollstaendig, mit Vorgesetztem und Umlaut im Namen
|
||||
# Klaus Berger ihr Vorgesetzter, damit `manager` aufloesbar ist
|
||||
# Joerg Weiss Nachname mit Eszett, Mail-Adresse mit Umlaut-Ersatz
|
||||
# Alte Kraft userAccountControl 514 (Bit 2 = deaktiviert) -- der
|
||||
# Standardfilter muss sie ausschliessen
|
||||
# Ohne Kennung ein Konto ohne objectGUID; ohne stabile Kennung darf der
|
||||
# Abgleich es nicht anlegen, aber auch nicht abbrechen
|
||||
#
|
||||
# objectGUID steht base64-kodiert (`::`), weil es im AD ein binaeres Attribut
|
||||
# ist. Die erwarteten GUID-Zeichenketten stehen in tests/verzeichnis.test.ts.
|
||||
#
|
||||
# Achtung: osixia laeuft mit `sed -i` ueber jede Datei in diesem Verzeichnis, um
|
||||
# Platzhalter wie {{ LDAP_BASE_DN }} zu ersetzen. Hier stehen deshalb keine
|
||||
# Platzhalter -- dann schreibt sed dieselben Bytes zurueck und der Arbeitsbaum
|
||||
# bleibt sauber.
|
||||
|
||||
dn: ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: organizationalUnit
|
||||
ou: Benutzer
|
||||
|
||||
dn: ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: organizationalUnit
|
||||
ou: Buchhaltung
|
||||
|
||||
dn: ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: organizationalUnit
|
||||
ou: Vertrieb
|
||||
|
||||
dn: ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: organizationalUnit
|
||||
ou: Geschaeftsfuehrung
|
||||
|
||||
dn: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: user
|
||||
cn: Klaus Berger
|
||||
sn: Berger
|
||||
givenName: Klaus
|
||||
displayName: Berger, Klaus
|
||||
mail: klaus.berger@firma.test
|
||||
userPrincipalName: klaus.berger@firma.test
|
||||
sAMAccountName: kberger
|
||||
department: Geschaeftsfuehrung
|
||||
title: Geschaeftsfuehrer
|
||||
objectCategory: person
|
||||
userAccountControl: 512
|
||||
objectGUID:: nEA+HXqyyUux5vCiw9Tl9g==
|
||||
|
||||
dn: cn=Maria Huber,ou=Buchhaltung,ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: user
|
||||
cn: Maria Huber
|
||||
sn: Huber
|
||||
givenName: Maria
|
||||
displayName:: SHViZXIsIE1hcmlh
|
||||
mail: maria.huber@firma.test
|
||||
userPrincipalName: maria.huber@firma.test
|
||||
sAMAccountName: mhuber
|
||||
department: Buchhaltung
|
||||
title:: TGVpdGVyaW4gUmVjaG51bmdzcHLDvGZ1bmc=
|
||||
objectCategory: person
|
||||
userAccountControl: 512
|
||||
manager: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
|
||||
objectGUID:: EEMHXzU8HUqDb6UPUV56NQ==
|
||||
|
||||
dn: cn=Joerg Weiss,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: user
|
||||
cn: Joerg Weiss
|
||||
sn:: V2Vpw58=
|
||||
givenName:: SsO2cmc=
|
||||
displayName:: V2Vpw58sIErDtnJn
|
||||
mail: joerg.weiss@firma.test
|
||||
userPrincipalName: joerg.weiss@firma.test
|
||||
sAMAccountName: jweiss
|
||||
department: Vertrieb
|
||||
objectCategory: person
|
||||
userAccountControl: 512
|
||||
manager: cn=Klaus Berger,ou=Geschaeftsfuehrung,ou=Benutzer,dc=firma,dc=test
|
||||
objectGUID:: AQIDBAUGBwgJCgsMDQ4PEA==
|
||||
|
||||
dn: cn=Alte Kraft,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: user
|
||||
cn: Alte Kraft
|
||||
sn: Kraft
|
||||
givenName: Alte
|
||||
displayName: Kraft, Alte
|
||||
mail: alte.kraft@firma.test
|
||||
userPrincipalName: alte.kraft@firma.test
|
||||
sAMAccountName: akraft
|
||||
department: Vertrieb
|
||||
objectCategory: person
|
||||
userAccountControl: 514
|
||||
objectGUID:: /+7dzLuqmYh3ZlVEMyIRAA==
|
||||
|
||||
dn: cn=Ohne Kennung,ou=Vertrieb,ou=Benutzer,dc=firma,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: user
|
||||
cn: Ohne Kennung
|
||||
sn: Kennung
|
||||
givenName: Ohne
|
||||
displayName: Kennung, Ohne
|
||||
sAMAccountName: okennung
|
||||
objectCategory: person
|
||||
userAccountControl: 512
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# AD-aehnliches Schema fuer den Testcontainer (#016).
|
||||
#
|
||||
# OpenLDAP bringt keines der Attribute mit, an denen der Abgleich haengt:
|
||||
# sAMAccountName, userPrincipalName, objectGUID, userAccountControl,
|
||||
# objectCategory und department fehlen alle. Ohne dieses Schema liefe der
|
||||
# Integrationstest gegen ein Verzeichnis, das mit einem Active Directory nichts
|
||||
# gemeinsam hat -- und bewiese entsprechend wenig.
|
||||
#
|
||||
# `manager`, `displayName`, `givenName`, `sn`, `mail` und `title` kommen aus
|
||||
# core/cosine/inetOrgPerson und stehen deshalb nicht hier.
|
||||
#
|
||||
# Die OIDs sind die echten aus dem Microsoft-Arc 1.2.840.113556, damit das
|
||||
# Fixture nicht bloss gleich heisst, sondern auch gleich bezeichnet ist.
|
||||
#
|
||||
# Zwei bewusste Abweichungen vom echten AD, weil sie sich in OpenLDAP nicht
|
||||
# nachbauen lassen:
|
||||
#
|
||||
# 1. `objectCategory` ist hier eine Zeichenkette mit dem Wert "person". Im AD
|
||||
# ist es ein DN auf das classSchema-Objekt, und der Server nimmt die
|
||||
# Kurzform "person" als Abkuerzung dafuer an. Der Standardfilter trifft
|
||||
# damit hier und dort, aus verschiedenen Gruenden.
|
||||
# 2. `user` ist hier AUXILIARY statt STRUCTURAL. So traegt ein Eintrag
|
||||
# inetOrgPerson **und** user, statt dass die Personenklassen des Fixtures
|
||||
# gegen die des AD ausgetauscht werden muessten. Fuer `(objectClass=user)`
|
||||
# macht das keinen Unterschied.
|
||||
|
||||
dn: cn=ad-fixture,cn=schema,cn=config
|
||||
objectClass: olcSchemaConfig
|
||||
cn: ad-fixture
|
||||
olcAttributeTypes: ( 1.2.840.113556.1.4.221 NAME 'sAMAccountName'
|
||||
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
|
||||
olcAttributeTypes: ( 1.2.840.113556.1.4.656 NAME 'userPrincipalName'
|
||||
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
|
||||
olcAttributeTypes: ( 1.2.840.113556.1.4.2 NAME 'objectGUID'
|
||||
EQUALITY octetStringMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
|
||||
olcAttributeTypes: ( 1.2.840.113556.1.4.8 NAME 'userAccountControl'
|
||||
EQUALITY integerMatch ORDERING integerOrderingMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
|
||||
olcAttributeTypes: ( 1.2.840.113556.1.4.782 NAME 'objectCategory'
|
||||
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
|
||||
olcAttributeTypes: ( 1.2.840.113556.1.2.141 NAME 'department'
|
||||
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
|
||||
olcObjectClasses: ( 1.2.840.113556.1.5.9 NAME 'user'
|
||||
DESC 'AD-aehnliche Zusatzklasse, nur fuer Tests'
|
||||
SUP top AUXILIARY
|
||||
MAY ( sAMAccountName $ userPrincipalName $ objectGUID $ userAccountControl $
|
||||
objectCategory $ department ) )
|
||||
Reference in New Issue
Block a user