schulsync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schulsync/__init__.py +7 -0
- schulsync/applier.py +185 -0
- schulsync/cli.py +423 -0
- schulsync/config.py +141 -0
- schulsync/csvsource.py +194 -0
- schulsync/ldapclient.py +392 -0
- schulsync/models.py +167 -0
- schulsync/passwords.py +42 -0
- schulsync/planner.py +172 -0
- schulsync/report.py +94 -0
- schulsync/templates/briefe.html.j2 +56 -0
- schulsync/templates/report.html.j2 +206 -0
- schulsync/usernames.py +91 -0
- schulsync-0.1.0.dist-info/METADATA +229 -0
- schulsync-0.1.0.dist-info/RECORD +18 -0
- schulsync-0.1.0.dist-info/WHEEL +4 -0
- schulsync-0.1.0.dist-info/entry_points.txt +2 -0
- schulsync-0.1.0.dist-info/licenses/LICENSE +21 -0
schulsync/ldapclient.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""LDAP-Anbindung an Active Directory (Windows AD oder Samba AD DC).
|
|
2
|
+
|
|
3
|
+
Designentscheidungen (im README ausführlicher begründet):
|
|
4
|
+
|
|
5
|
+
* **CN = Benutzername**, der Anzeigename steht in ``displayName``.
|
|
6
|
+
Zwei »Ben Yılmaz« in derselben Klasse wären sonst eine RDN-Kollision –
|
|
7
|
+
der Benutzername ist dagegen domänenweit eindeutig, OU-Umzüge können
|
|
8
|
+
nie kollidieren.
|
|
9
|
+
* Die Quell-ID der Schulverwaltung liegt in ``employeeNumber``,
|
|
10
|
+
die Rolle in ``employeeType`` – beides Standard-AD-Attribute,
|
|
11
|
+
kein Schema-Gefrickel nötig.
|
|
12
|
+
* Passwörter (``unicodePwd``) verlangen eine **verschlüsselte Verbindung**:
|
|
13
|
+
LDAPS oder STARTTLS. Unverschlüsselt verweigert SchulSync den Dienst.
|
|
14
|
+
* Deaktivierte Konten bekommen einen maschinenlesbaren Stempel in
|
|
15
|
+
``description`` – daraus liest ``cleanup`` die Aufbewahrungsfrist.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import ssl
|
|
22
|
+
from datetime import date
|
|
23
|
+
|
|
24
|
+
from ldap3 import (
|
|
25
|
+
ALL,
|
|
26
|
+
MODIFY_ADD,
|
|
27
|
+
MODIFY_DELETE,
|
|
28
|
+
MODIFY_REPLACE,
|
|
29
|
+
SIMPLE,
|
|
30
|
+
SUBTREE,
|
|
31
|
+
Connection,
|
|
32
|
+
Server,
|
|
33
|
+
Tls,
|
|
34
|
+
)
|
|
35
|
+
from ldap3.core.exceptions import (
|
|
36
|
+
LDAPAttributeOrValueExistsResult,
|
|
37
|
+
LDAPEntryAlreadyExistsResult,
|
|
38
|
+
LDAPException,
|
|
39
|
+
LDAPNoSuchAttributeResult,
|
|
40
|
+
LDAPNoSuchObjectResult,
|
|
41
|
+
)
|
|
42
|
+
from ldap3.utils.conv import escape_filter_chars
|
|
43
|
+
from ldap3.utils.dn import escape_rdn
|
|
44
|
+
|
|
45
|
+
from .config import Config
|
|
46
|
+
from .models import IstKonto, Rolle
|
|
47
|
+
|
|
48
|
+
UAC_NORMAL = 0x0200 # 512
|
|
49
|
+
UAC_DISABLED = 0x0202 # 514 (ACCOUNTDISABLE-Bit gesetzt)
|
|
50
|
+
GRUPPE_GLOBAL_SECURITY = -2147483646
|
|
51
|
+
|
|
52
|
+
_STEMPEL_RE = re.compile(r"deaktiviert am (\d{4}-\d{2}-\d{2})", re.IGNORECASE)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def deaktivierungs_stempel(am: date, frist_tage: int) -> str:
|
|
56
|
+
return f"SchulSync: deaktiviert am {am.isoformat()} – Löschung nach {frist_tage} Tagen Frist"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_stempel(description: str | None) -> date | None:
|
|
60
|
+
if not description:
|
|
61
|
+
return None
|
|
62
|
+
m = _STEMPEL_RE.search(description)
|
|
63
|
+
return date.fromisoformat(m.group(1)) if m else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LdapFehler(Exception):
|
|
67
|
+
"""LDAP-Fehler mit verständlicher Meldung für die CLI."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AdVerbindung:
|
|
71
|
+
"""Dünne, gut testbare Schicht über ldap3 – jede Methode ist eine
|
|
72
|
+
fachliche Operation, kein generischer LDAP-Wrapper."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, config: Config):
|
|
75
|
+
self.config = config
|
|
76
|
+
self._conn: Connection | None = None
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------- Aufbau
|
|
79
|
+
def verbinden(self) -> None:
|
|
80
|
+
c = self.config.ldap
|
|
81
|
+
url = c.server
|
|
82
|
+
use_ssl = url.startswith("ldaps://")
|
|
83
|
+
|
|
84
|
+
tls = Tls(
|
|
85
|
+
validate=ssl.CERT_REQUIRED if c.tls_verifizieren else ssl.CERT_NONE,
|
|
86
|
+
version=ssl.PROTOCOL_TLS_CLIENT if c.tls_verifizieren else ssl.PROTOCOL_TLS,
|
|
87
|
+
)
|
|
88
|
+
# ldap3 versteht vollständige LDAP-URLs inkl. Port (ldaps://host:636).
|
|
89
|
+
server = Server(
|
|
90
|
+
url,
|
|
91
|
+
tls=tls,
|
|
92
|
+
get_info=ALL,
|
|
93
|
+
connect_timeout=c.zeitlimit_sekunden,
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
conn = Connection(
|
|
97
|
+
server,
|
|
98
|
+
user=c.bind_benutzer,
|
|
99
|
+
password=c.passwort,
|
|
100
|
+
authentication=SIMPLE,
|
|
101
|
+
raise_exceptions=True,
|
|
102
|
+
receive_timeout=c.zeitlimit_sekunden * 3,
|
|
103
|
+
auto_bind=False,
|
|
104
|
+
)
|
|
105
|
+
conn.open()
|
|
106
|
+
if not use_ssl:
|
|
107
|
+
# Ohne Verschlüsselung keine Passwörter: STARTTLS erzwingen.
|
|
108
|
+
conn.start_tls()
|
|
109
|
+
conn.bind()
|
|
110
|
+
except LDAPException as e:
|
|
111
|
+
raise LdapFehler(
|
|
112
|
+
f"Verbindung zu {url} als '{c.bind_benutzer}' fehlgeschlagen: {e}"
|
|
113
|
+
) from e
|
|
114
|
+
self._conn = conn
|
|
115
|
+
|
|
116
|
+
def trennen(self) -> None:
|
|
117
|
+
if self._conn is not None:
|
|
118
|
+
self._conn.unbind()
|
|
119
|
+
self._conn = None
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> AdVerbindung:
|
|
122
|
+
self.verbinden()
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, *exc) -> None:
|
|
126
|
+
self.trennen()
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def conn(self) -> Connection:
|
|
130
|
+
if self._conn is None:
|
|
131
|
+
raise LdapFehler("Nicht verbunden – verbinden() zuerst aufrufen.")
|
|
132
|
+
return self._conn
|
|
133
|
+
|
|
134
|
+
# -------------------------------------------------------------- Lesen
|
|
135
|
+
def basis_existiert(self) -> bool:
|
|
136
|
+
try:
|
|
137
|
+
return self.conn.search(
|
|
138
|
+
self.config.basis_dn, "(objectClass=organizationalUnit)", search_scope="BASE"
|
|
139
|
+
)
|
|
140
|
+
except LDAPNoSuchObjectResult:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def alle_benutzernamen_der_domaene(self) -> set[str]:
|
|
144
|
+
"""Alle sAMAccountNames der gesamten Domäne – neue Benutzernamen
|
|
145
|
+
dürfen auch mit nicht von SchulSync verwalteten Konten (Sekretariat,
|
|
146
|
+
Hausmeister, Dienstkonten) nicht kollidieren."""
|
|
147
|
+
self.conn.search(
|
|
148
|
+
self.config.ldap.base_dn,
|
|
149
|
+
"(sAMAccountName=*)",
|
|
150
|
+
search_scope=SUBTREE,
|
|
151
|
+
attributes=["sAMAccountName"],
|
|
152
|
+
)
|
|
153
|
+
return {str(e["sAMAccountName"].value) for e in self.conn.entries}
|
|
154
|
+
|
|
155
|
+
def lade_ist_konten(self) -> list[IstKonto]:
|
|
156
|
+
"""Liest alle von SchulSync verwalteten Konten (unterhalb der Basis-OU)."""
|
|
157
|
+
if not self.basis_existiert():
|
|
158
|
+
return []
|
|
159
|
+
self.conn.search(
|
|
160
|
+
self.config.basis_dn,
|
|
161
|
+
"(&(objectCategory=person)(objectClass=user))",
|
|
162
|
+
search_scope=SUBTREE,
|
|
163
|
+
attributes=[
|
|
164
|
+
"sAMAccountName",
|
|
165
|
+
"employeeNumber",
|
|
166
|
+
"employeeType",
|
|
167
|
+
"givenName",
|
|
168
|
+
"sn",
|
|
169
|
+
"userAccountControl",
|
|
170
|
+
"description",
|
|
171
|
+
],
|
|
172
|
+
)
|
|
173
|
+
konten: list[IstKonto] = []
|
|
174
|
+
for e in self.conn.entries:
|
|
175
|
+
dn = str(e.entry_dn)
|
|
176
|
+
uac = int(str(e["userAccountControl"].value or UAC_NORMAL))
|
|
177
|
+
beschreibung = str(e["description"].value) if e["description"] else None
|
|
178
|
+
rolle_roh = str(e["employeeType"].value) if e["employeeType"] else ""
|
|
179
|
+
konten.append(
|
|
180
|
+
IstKonto(
|
|
181
|
+
dn=dn,
|
|
182
|
+
benutzername=str(e["sAMAccountName"].value),
|
|
183
|
+
quell_id=str(e["employeeNumber"].value) if e["employeeNumber"] else None,
|
|
184
|
+
vorname=str(e["givenName"].value) if e["givenName"] else "",
|
|
185
|
+
nachname=str(e["sn"].value) if e["sn"] else "",
|
|
186
|
+
rolle=Rolle(rolle_roh) if rolle_roh in ("schueler", "lehrkraft")
|
|
187
|
+
else Rolle.SCHUELER,
|
|
188
|
+
klasse=self._klasse_aus_dn(dn),
|
|
189
|
+
aktiv=not (uac & 0x2),
|
|
190
|
+
deaktiviert_am=parse_stempel(beschreibung),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
konten.sort(key=lambda k: (k.klasse or "", k.nachname, k.vorname))
|
|
194
|
+
return konten
|
|
195
|
+
|
|
196
|
+
def _klasse_aus_dn(self, dn: str) -> str | None:
|
|
197
|
+
"""CN=maja.weber,OU=5A,OU=Schueler,… → ``5A``."""
|
|
198
|
+
suffix = "," + self.config.schueler_dn.lower()
|
|
199
|
+
if not dn.lower().endswith(suffix):
|
|
200
|
+
return None # Lehrkraft oder Abgänger
|
|
201
|
+
mitte = dn[: -len(suffix)]
|
|
202
|
+
teile = mitte.split(",")
|
|
203
|
+
if len(teile) >= 2 and teile[-1].upper().startswith("OU="):
|
|
204
|
+
return teile[-1][3:]
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------- Struktur
|
|
208
|
+
def stelle_struktur_sicher(self, klassen: set[str]) -> list[str]:
|
|
209
|
+
"""Legt Basis-OUs, Klassen-OUs und Gruppen an, falls sie fehlen.
|
|
210
|
+
Idempotent. Gibt die neu angelegten Objekte zurück."""
|
|
211
|
+
neu: list[str] = []
|
|
212
|
+
s = self.config
|
|
213
|
+
ous = [
|
|
214
|
+
s.basis_dn,
|
|
215
|
+
s.schueler_dn,
|
|
216
|
+
s.lehrkraefte_dn,
|
|
217
|
+
s.abgaenger_dn,
|
|
218
|
+
*(s.klassen_dn(k) for k in sorted(klassen)),
|
|
219
|
+
]
|
|
220
|
+
for dn in ous:
|
|
221
|
+
if self._lege_ou_an(dn):
|
|
222
|
+
neu.append(dn)
|
|
223
|
+
gruppen = [
|
|
224
|
+
s.struktur.gruppe_alle_schueler,
|
|
225
|
+
s.struktur.gruppe_alle_lehrkraefte,
|
|
226
|
+
*(s.klassen_gruppe(k) for k in sorted(klassen)),
|
|
227
|
+
]
|
|
228
|
+
for name in gruppen:
|
|
229
|
+
if self._lege_gruppe_an(name):
|
|
230
|
+
neu.append(f"Gruppe {name}")
|
|
231
|
+
return neu
|
|
232
|
+
|
|
233
|
+
def _lege_ou_an(self, dn: str) -> bool:
|
|
234
|
+
try:
|
|
235
|
+
self.conn.add(dn, attributes={"objectClass": ["top", "organizationalUnit"]})
|
|
236
|
+
return True
|
|
237
|
+
except LDAPEntryAlreadyExistsResult:
|
|
238
|
+
return False
|
|
239
|
+
except LDAPException as e:
|
|
240
|
+
raise LdapFehler(f"OU '{dn}' konnte nicht angelegt werden: {e}") from e
|
|
241
|
+
|
|
242
|
+
def _gruppen_dn(self, name: str) -> str:
|
|
243
|
+
return f"CN={escape_rdn(name)},{self.config.basis_dn}"
|
|
244
|
+
|
|
245
|
+
def _lege_gruppe_an(self, name: str) -> bool:
|
|
246
|
+
try:
|
|
247
|
+
self.conn.add(
|
|
248
|
+
self._gruppen_dn(name),
|
|
249
|
+
attributes={
|
|
250
|
+
"objectClass": ["top", "group"],
|
|
251
|
+
"sAMAccountName": name,
|
|
252
|
+
"groupType": GRUPPE_GLOBAL_SECURITY,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
return True
|
|
256
|
+
except LDAPEntryAlreadyExistsResult:
|
|
257
|
+
return False
|
|
258
|
+
except LDAPException as e:
|
|
259
|
+
raise LdapFehler(f"Gruppe '{name}' konnte nicht angelegt werden: {e}") from e
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------ Konten
|
|
262
|
+
def benutzer_dn(self, benutzername: str, eltern_dn: str) -> str:
|
|
263
|
+
return f"CN={escape_rdn(benutzername)},{eltern_dn}"
|
|
264
|
+
|
|
265
|
+
def lege_benutzer_an(
|
|
266
|
+
self,
|
|
267
|
+
benutzername: str,
|
|
268
|
+
vorname: str,
|
|
269
|
+
nachname: str,
|
|
270
|
+
quell_id: str,
|
|
271
|
+
rolle: Rolle,
|
|
272
|
+
eltern_dn: str,
|
|
273
|
+
passwort: str,
|
|
274
|
+
) -> str:
|
|
275
|
+
"""Dreischritt, wie AD ihn verlangt: anlegen (deaktiviert) →
|
|
276
|
+
Passwort setzen (nur über TLS möglich) → aktivieren + Passwortwechsel
|
|
277
|
+
bei erster Anmeldung erzwingen."""
|
|
278
|
+
dn = self.benutzer_dn(benutzername, eltern_dn)
|
|
279
|
+
attribute = {
|
|
280
|
+
"objectClass": ["top", "person", "organizationalPerson", "user"],
|
|
281
|
+
"sAMAccountName": benutzername,
|
|
282
|
+
"userPrincipalName": f"{benutzername}{self.config.konten.upn_suffix}",
|
|
283
|
+
"givenName": vorname,
|
|
284
|
+
"sn": nachname,
|
|
285
|
+
"displayName": f"{vorname} {nachname}",
|
|
286
|
+
"employeeNumber": quell_id,
|
|
287
|
+
"employeeType": rolle.value,
|
|
288
|
+
}
|
|
289
|
+
k = self.config.konten
|
|
290
|
+
if k.home_laufwerk and k.home_pfad:
|
|
291
|
+
attribute["homeDrive"] = k.home_laufwerk
|
|
292
|
+
attribute["homeDirectory"] = k.home_pfad.format(benutzername=benutzername)
|
|
293
|
+
try:
|
|
294
|
+
self.conn.add(dn, attributes=attribute)
|
|
295
|
+
self.setze_passwort(dn, passwort)
|
|
296
|
+
self.conn.modify(
|
|
297
|
+
dn,
|
|
298
|
+
{
|
|
299
|
+
"userAccountControl": [(MODIFY_REPLACE, [UAC_NORMAL])],
|
|
300
|
+
"pwdLastSet": [(MODIFY_REPLACE, [0])], # Änderung bei 1. Anmeldung
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
except LDAPException as e:
|
|
304
|
+
raise LdapFehler(f"Konto '{benutzername}' konnte nicht angelegt werden: {e}") from e
|
|
305
|
+
return dn
|
|
306
|
+
|
|
307
|
+
def setze_passwort(self, dn: str, passwort: str) -> None:
|
|
308
|
+
self.conn.modify(
|
|
309
|
+
dn, {"unicodePwd": [(MODIFY_REPLACE, [f'"{passwort}"'.encode("utf-16-le")])]}
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def verschiebe(self, dn: str, ziel_ou: str) -> str:
|
|
313
|
+
rdn = dn.split(",", 1)[0]
|
|
314
|
+
try:
|
|
315
|
+
self.conn.modify_dn(dn, rdn, new_superior=ziel_ou)
|
|
316
|
+
except LDAPException as e:
|
|
317
|
+
raise LdapFehler(f"'{dn}' → '{ziel_ou}' fehlgeschlagen: {e}") from e
|
|
318
|
+
return f"{rdn},{ziel_ou}"
|
|
319
|
+
|
|
320
|
+
def benenne_um(self, dn: str, vorname: str, nachname: str) -> None:
|
|
321
|
+
try:
|
|
322
|
+
self.conn.modify(
|
|
323
|
+
dn,
|
|
324
|
+
{
|
|
325
|
+
"givenName": [(MODIFY_REPLACE, [vorname])],
|
|
326
|
+
"sn": [(MODIFY_REPLACE, [nachname])],
|
|
327
|
+
"displayName": [(MODIFY_REPLACE, [f"{vorname} {nachname}"])],
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
except LDAPException as e:
|
|
331
|
+
raise LdapFehler(f"Umbenennen von '{dn}' fehlgeschlagen: {e}") from e
|
|
332
|
+
|
|
333
|
+
def deaktiviere(self, dn: str, am: date, frist_tage: int) -> None:
|
|
334
|
+
try:
|
|
335
|
+
self.conn.modify(
|
|
336
|
+
dn,
|
|
337
|
+
{
|
|
338
|
+
"userAccountControl": [(MODIFY_REPLACE, [UAC_DISABLED])],
|
|
339
|
+
"description": [(MODIFY_REPLACE, [deaktivierungs_stempel(am, frist_tage)])],
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
except LDAPException as e:
|
|
343
|
+
raise LdapFehler(f"Deaktivieren von '{dn}' fehlgeschlagen: {e}") from e
|
|
344
|
+
|
|
345
|
+
def aktiviere(self, dn: str) -> None:
|
|
346
|
+
try:
|
|
347
|
+
self.conn.modify(
|
|
348
|
+
dn,
|
|
349
|
+
{
|
|
350
|
+
"userAccountControl": [(MODIFY_REPLACE, [UAC_NORMAL])],
|
|
351
|
+
"description": [(MODIFY_REPLACE, [])],
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
except LDAPException as e:
|
|
355
|
+
raise LdapFehler(f"Aktivieren von '{dn}' fehlgeschlagen: {e}") from e
|
|
356
|
+
|
|
357
|
+
def loesche(self, dn: str) -> None:
|
|
358
|
+
try:
|
|
359
|
+
self.conn.delete(dn)
|
|
360
|
+
except LDAPException as e:
|
|
361
|
+
raise LdapFehler(f"Löschen von '{dn}' fehlgeschlagen: {e}") from e
|
|
362
|
+
|
|
363
|
+
# ----------------------------------------------------------- Gruppen
|
|
364
|
+
def fuege_zu_gruppe_hinzu(self, benutzer_dn: str, gruppe: str) -> None:
|
|
365
|
+
try:
|
|
366
|
+
self.conn.modify(
|
|
367
|
+
self._gruppen_dn(gruppe), {"member": [(MODIFY_ADD, [benutzer_dn])]}
|
|
368
|
+
)
|
|
369
|
+
except (LDAPAttributeOrValueExistsResult, LDAPEntryAlreadyExistsResult):
|
|
370
|
+
pass # schon Mitglied – idempotent
|
|
371
|
+
except LDAPException as e:
|
|
372
|
+
raise LdapFehler(f"'{benutzer_dn}' → Gruppe '{gruppe}' fehlgeschlagen: {e}") from e
|
|
373
|
+
|
|
374
|
+
def entferne_aus_gruppe(self, benutzer_dn: str, gruppe: str) -> None:
|
|
375
|
+
try:
|
|
376
|
+
self.conn.modify(
|
|
377
|
+
self._gruppen_dn(gruppe), {"member": [(MODIFY_DELETE, [benutzer_dn])]}
|
|
378
|
+
)
|
|
379
|
+
except (LDAPNoSuchAttributeResult, LDAPNoSuchObjectResult):
|
|
380
|
+
pass # war kein Mitglied / Gruppe existiert nicht – idempotent
|
|
381
|
+
except LDAPException as e:
|
|
382
|
+
raise LdapFehler(f"'{benutzer_dn}' aus '{gruppe}' entfernen fehlgeschlagen: {e}") from e
|
|
383
|
+
|
|
384
|
+
def gruppen_von(self, benutzer_dn: str) -> list[str]:
|
|
385
|
+
"""Alle SchulSync-Gruppen, in denen der Benutzer Mitglied ist."""
|
|
386
|
+
self.conn.search(
|
|
387
|
+
self.config.basis_dn,
|
|
388
|
+
f"(&(objectClass=group)(member={escape_filter_chars(benutzer_dn)}))",
|
|
389
|
+
search_scope=SUBTREE,
|
|
390
|
+
attributes=["sAMAccountName"],
|
|
391
|
+
)
|
|
392
|
+
return [str(e["sAMAccountName"].value) for e in self.conn.entries]
|
schulsync/models.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Domänenmodell von SchulSync.
|
|
2
|
+
|
|
3
|
+
Zentrale Idee (Terraform-Prinzip):
|
|
4
|
+
* Der CSV-Export der Schulverwaltung beschreibt den **Soll-Zustand**.
|
|
5
|
+
* Das Active Directory enthält den **Ist-Zustand**.
|
|
6
|
+
* Der Planner berechnet daraus einen nachvollziehbaren Plan,
|
|
7
|
+
der erst nach expliziter Bestätigung angewendet wird.
|
|
8
|
+
|
|
9
|
+
Als stabiler Schlüssel zwischen beiden Welten dient die ID aus der
|
|
10
|
+
Schulverwaltung (SchILD: "Interne ID-Nummer", ASV: "Schülernummer").
|
|
11
|
+
Sie wird im AD-Attribut ``employeeNumber`` gespeichert – Namen und
|
|
12
|
+
Klassen dürfen sich ändern, die ID nicht.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import date
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Rolle(StrEnum):
|
|
24
|
+
SCHUELER = "schueler"
|
|
25
|
+
LEHRKRAFT = "lehrkraft"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_KLASSE_RE = re.compile(r"^([0-9]{1,2})\s*([A-Za-z]{0,3})$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalisiere_klasse(roh: str) -> str:
|
|
32
|
+
"""Normalisiert Klassenbezeichnungen: ``5a`` → ``5A``, `` 10 b`` → ``10B``.
|
|
33
|
+
|
|
34
|
+
Bezeichnungen ohne Jahrgangsmuster (z. B. Kursstufen wie ``JG1``)
|
|
35
|
+
werden nur getrimmt und großgeschrieben.
|
|
36
|
+
"""
|
|
37
|
+
roh = roh.strip()
|
|
38
|
+
m = _KLASSE_RE.match(roh)
|
|
39
|
+
if m:
|
|
40
|
+
return f"{int(m.group(1))}{m.group(2).upper()}"
|
|
41
|
+
return roh.upper().replace(" ", "-")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class SollPerson:
|
|
46
|
+
"""Eine Person, wie die Schulverwaltung sie exportiert (Soll-Zustand)."""
|
|
47
|
+
|
|
48
|
+
quell_id: str
|
|
49
|
+
vorname: str
|
|
50
|
+
nachname: str
|
|
51
|
+
rolle: Rolle
|
|
52
|
+
klasse: str | None = None # nur für Schüler:innen
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def anzeigename(self) -> str:
|
|
56
|
+
return f"{self.vorname} {self.nachname}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class IstKonto:
|
|
61
|
+
"""Ein von SchulSync verwaltetes Konto, wie es im AD existiert (Ist-Zustand)."""
|
|
62
|
+
|
|
63
|
+
dn: str
|
|
64
|
+
benutzername: str # sAMAccountName
|
|
65
|
+
quell_id: str | None
|
|
66
|
+
vorname: str
|
|
67
|
+
nachname: str
|
|
68
|
+
rolle: Rolle
|
|
69
|
+
klasse: str | None
|
|
70
|
+
aktiv: bool
|
|
71
|
+
deaktiviert_am: date | None = None # aus dem description-Stempel geparst
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def anzeigename(self) -> str:
|
|
75
|
+
return f"{self.vorname} {self.nachname}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Plan-Aktionen
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class Anlegen:
|
|
85
|
+
person: SollPerson
|
|
86
|
+
benutzername: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class Verschieben:
|
|
91
|
+
"""Klassenwechsel: OU-Umzug + Gruppen-Update (z. B. 5A → 6A)."""
|
|
92
|
+
|
|
93
|
+
konto: IstKonto
|
|
94
|
+
von_klasse: str | None
|
|
95
|
+
nach_klasse: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class Umbenennen:
|
|
100
|
+
"""Namensänderung in der Schulverwaltung (Heirat, Korrektur …)."""
|
|
101
|
+
|
|
102
|
+
konto: IstKonto
|
|
103
|
+
neuer_vorname: str
|
|
104
|
+
neuer_nachname: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class Deaktivieren:
|
|
109
|
+
"""Abgänger: Konto wird deaktiviert und in die Abgänger-OU verschoben.
|
|
110
|
+
|
|
111
|
+
Gelöscht wird erst nach Ablauf der Aufbewahrungsfrist durch
|
|
112
|
+
``schulsync cleanup`` – niemals sofort (Löschkonzept, Art. 17 DSGVO).
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
konto: IstKonto
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class Reaktivieren:
|
|
120
|
+
"""Rückkehrer: war Abgänger, taucht im Export wieder auf."""
|
|
121
|
+
|
|
122
|
+
konto: IstKonto
|
|
123
|
+
nach_klasse: str | None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class Loeschen:
|
|
128
|
+
"""Endgültige Löschung nach Ablauf der Aufbewahrungsfrist (nur `cleanup`)."""
|
|
129
|
+
|
|
130
|
+
konto: IstKonto
|
|
131
|
+
deaktiviert_am: date
|
|
132
|
+
frist_tage: int
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class Konflikt:
|
|
137
|
+
"""Ein Datensatz, der nicht automatisch verarbeitet werden kann."""
|
|
138
|
+
|
|
139
|
+
betrifft: str
|
|
140
|
+
grund: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(slots=True)
|
|
144
|
+
class Plan:
|
|
145
|
+
"""Ergebnis des Soll/Ist-Vergleichs. Reihenfolge der Listen ist deterministisch."""
|
|
146
|
+
|
|
147
|
+
anlegen: list[Anlegen] = field(default_factory=list)
|
|
148
|
+
verschieben: list[Verschieben] = field(default_factory=list)
|
|
149
|
+
umbenennen: list[Umbenennen] = field(default_factory=list)
|
|
150
|
+
deaktivieren: list[Deaktivieren] = field(default_factory=list)
|
|
151
|
+
reaktivieren: list[Reaktivieren] = field(default_factory=list)
|
|
152
|
+
konflikte: list[Konflikt] = field(default_factory=list)
|
|
153
|
+
unveraendert: int = 0
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def anzahl_aenderungen(self) -> int:
|
|
157
|
+
return (
|
|
158
|
+
len(self.anlegen)
|
|
159
|
+
+ len(self.verschieben)
|
|
160
|
+
+ len(self.umbenennen)
|
|
161
|
+
+ len(self.deaktivieren)
|
|
162
|
+
+ len(self.reaktivieren)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def leer(self) -> bool:
|
|
167
|
+
return self.anzahl_aenderungen == 0
|
schulsync/passwords.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Initialpasswörter.
|
|
2
|
+
|
|
3
|
+
Anforderungen aus der Praxis:
|
|
4
|
+
|
|
5
|
+
* Ein Fünftklässler muss es **abtippen** können → Wortpaare statt Zeichensalat.
|
|
6
|
+
* AD-Komplexitätsrichtlinie (3 von 4 Zeichenklassen) muss erfüllt sein
|
|
7
|
+
→ Großbuchstabe + Kleinbuchstaben + Ziffern sind immer enthalten.
|
|
8
|
+
* Es ist ein **Einmalpasswort**: ``pwdLastSet = 0`` erzwingt die Änderung
|
|
9
|
+
bei der ersten Anmeldung. Die Stärke muss den Schulflur bis zur ersten
|
|
10
|
+
Anmeldung überleben, keinen Offline-Angriff über Jahre.
|
|
11
|
+
* Kryptografisch sauberer Zufall via ``secrets``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import secrets
|
|
17
|
+
|
|
18
|
+
# Kindgerechte, eindeutig tippbare Wörter (keine Umlaute, kein ß):
|
|
19
|
+
WOERTER = (
|
|
20
|
+
"Adler", "Ampel", "Anker", "Apfel", "Banane", "Baum", "Berg", "Biber",
|
|
21
|
+
"Blitz", "Brille", "Delfin", "Drache", "Eiche", "Ente", "Erdbeere",
|
|
22
|
+
"Falke", "Feder", "Fichte", "Flamme", "Garten", "Gipfel", "Hafen",
|
|
23
|
+
"Herbst", "Himmel", "Honig", "Igel", "Insel", "Kaktus", "Kirsche",
|
|
24
|
+
"Koala", "Komet", "Kompass", "Kranich", "Lampe", "Leuchtturm", "Linde",
|
|
25
|
+
"Melone", "Mond", "Morgen", "Nebel", "Orange", "Panda", "Pilot",
|
|
26
|
+
"Pinguin", "Rakete", "Regen", "Robbe", "Salat", "Sommer", "Sonne",
|
|
27
|
+
"Stern", "Sturm", "Tiger", "Tomate", "Traktor", "Wal", "Wiese",
|
|
28
|
+
"Winter", "Wolke", "Zebra",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
MIN_LAENGE = 12
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def erzeuge_passwort(anzahl_woerter: int = 2) -> str:
|
|
35
|
+
"""Erzeugt z. B. ``Tiger-Wolke-83`` – merkbar, tippbar, AD-konform."""
|
|
36
|
+
anzahl_woerter = max(2, anzahl_woerter)
|
|
37
|
+
while True:
|
|
38
|
+
woerter = [secrets.choice(WOERTER) for _ in range(anzahl_woerter)]
|
|
39
|
+
ziffern = f"{secrets.randbelow(90) + 10}" # zweistellig, keine führende 0
|
|
40
|
+
pw = "-".join([*woerter, ziffern])
|
|
41
|
+
if len(pw) >= MIN_LAENGE:
|
|
42
|
+
return pw
|