How to: Import workgroups into UCS@school using the Kelvin REST API
Administrators may want to import workgroups into a UCS@school environment in the same way that users and school classes can be imported.
The UCS@school documentation describes the available import mechanisms for users and school classes:
https://docs.software-univention.de/ucsschool-manual/5.2/de/manage-school-imports.html#school-setup-cli-classes
The import of workgroups is not covered by the documented import tools.
As an alternative, workgroups can be created by using the UCS@school Kelvin REST API directly from the command line.
Hint:
This approach deviates from the standard product workflow. For this reason, it is not documented in the official documentation and is therefore not supported.
Investigation
The standard import script
/usr/share/ucs-school-import/scripts/import_group
does not recognize workgroups. According to the current Univention documentation, it is designed exclusively for importing school classes.
The script also does not distinguish between school classes and workgroups. The CSV format does not contain a field specifying the group type. Instead, it only supports the following information:
- Action
- School (OU)
- Group name
- Description
Regardless of the CSV content, the script always creates the LDAP object below:
cn=<groupname>,cn=klassen,cn=schueler,cn=groups,ou=<OU>,<LDAP_BASE>
Within UCS@school, the distinction between a school class and a workgroup is not defined by an explicit type field during import. Instead, it is determined by two characteristics.
LDAP location
School classes are stored below:
cn=klassen,cn=schueler,cn=groups,ou=<OU>
Cross-class workgroups are stored directly below:
cn=schueler,cn=groups,ou=<OU>
without the intermediate cn=klassen container.
ucsschoolRole attribute
School classes receive an attribute similar to:
school_class:school:<OU>
whereas workgroups receive:
workgroup:school:<OU>
Since the import_group script hardcodes both the LDAP location and the ucsschoolRole attribute for school classes, it cannot be used to create workgroups.
One of the defining characteristics of a UCS@school workgroup is the following LDAP attribute:
ucsschoolRole: workgroup:school:<OU>
1. Prerequisite: Install the Kelvin App
The UCS@school Kelvin REST API is provided as an App Center application running on the Primary Directory Node (or Backup Directory Node). It exposes REST endpoints for schools, users, classes, computer rooms, and workgroups.
Install the application:
univention-app install ucsschool-kelvin-rest-api
Verify the installation:
univention-app info
Example:
UCS: 5.2-6 errata492
Installed: ucsschool=5.2v6 ucsschool-kelvin-rest-api=3.3.1
2. Prerequisite: Install Python venv and pip
Install the required Python packages, create venv and activate a Python virtual environment:
univention-install python3-venv python3-pippython3 -m venv venvsource venv/bin/activatepip install --upgrade pippip install kelvin-rest-api-client
As long as the virtual environment is active, (using the shebang #!/usr/bin/env python3) automatically uses the Python interpreter from the virtual environment.
Example:
(venv) root@ucs-primary:~/univention-support/import_workgroups/kelvin_rest_api#
3. Configure the Kelvin host and user
Configure the hostname of the Kelvin REST API and the administrative user that will authenticate against the API.
export KELVIN_HOST="$(hostname -f)"export KELVIN_USER="Administrator"
4. Prepare the CSV file
The CSV layout follows a straightforward schema.
If existing UCS@school users (students, teachers, etc.) should automatically become members of the imported workgroups, specify their usernames in the members column. Multiple users must be separated by semicolons.
Example:
cat workgroups_kelvin_example.csv
ou name description members
Heisenberg AG-Kunst Arbeitsgruppe Kunst t.test;a.mueller
Heisenberg AG-Sport Arbeitsgruppe Sport t.test
5. Execute the import script
The provided script imports the workgroups defined in the CSV file by using the UCS@school Kelvin REST API. During the import, the corresponding UCS@school workgroups and their associated Samba shares are created automatically.
When the script is executed without the --apply parameter, it performs a dry run. This allows validating the CSV file and identifying potential problems before any LDAP objects are created.
./import_workgroups_kelvin.py workgroups.csv # Dry-Run
./import_workgroups_kelvin.py workgroups.csv --apply # create workgroups
The --verify parameter have to be used to explicitly specify the internal UCS Certificate Authority:
/etc/univention/ssl/ucsCA/CAcert.pem
If certificate verification is not required, it can be disabled by specifying:
--verify=false
If the script should be executed for example from a cron job, call the Python interpreter inside the virtual environment explicitly:
~/univention-support/import_workgroups/venv/bin/python3 \
import_workgroups_kelvin.py workgroups_kelvin_example.csv
Create the workgroups with the script and verify with the ucsCA:
./import_workgroups_kelvin.py workgroups_kelvin_example.csv --apply --verify /etc/univention/ssl/ucsCA/CAcert.pem
The Kelvin password is requested interactively unless it is provided using the
KELVIN_PASSWORDenvironment variable for automated executions.
Example output:
(venv) root@ucs-primary:~/univention-support/import_workgroups/kelvin_rest_api# ./import_workgroups_kelvin.py workgroups_kelvin_example.csv --apply --verify /etc/univention/ssl/ucsCA/CAcert.pem
=== UCS@school Kelvin Workgroup Import ===
CSV file : workgroups_kelvin_example.csv
Mode : APPLY - Workgroups will be created
Kelvin password for Administrator@ucs-primary.example.net:
Line 2: 'AG-Kunst' (School: Heisenberg) - creating (Members: ['t.test', 'a.mueller'])
Line 3: 'AG-Sport' (School: Heisenberg) - creating (Members: ['t.test'])
=== Finished ===
Created: 2
Skipped (already existing): 0
Errors: 0
Python script:
import_workgroups_kelvin.py (6.3 KB)
#!/usr/bin/env python3
"""
import_workgroups_kelvin.py – Bulk-Import von UCS@school-Arbeitsgruppen
über die UCS@school Kelvin REST API (offizieller, unterstützter Weg).
Voraussetzungen:
- UCS@school Kelvin REST API App ist installiert (DC Master oder Backup)
- Python-Paket "kelvin-rest-api-client" ist installiert:
pip install kelvin-rest-api-client --break-system-packages
- Ein Kelvin-fähiger Account (z.B. Administrator) mit Passwort
CSV-FORMAT: Tab-getrennt (\t), UTF-8, mit Kopfzeile:
ou<TAB>name<TAB>description<TAB>members
"members" ist optional: eine mit Semikolon (;) getrennte Liste von
UCS@school-BENUTZERNAMEN (keine DNs! - anders als beim UDM-Weg).
Beispielzeile (Tabs hier als Leerzeichen dargestellt):
Heisenberg AG-Kunst Arbeitsgruppe Kunst t.test;m.mueller
AUFRUF:
export KELVIN_HOST="$(hostname -f)"
export KELVIN_USER="Administrator"
./import_workgroups_kelvin.py workgroups.csv # Dry-Run
./import_workgroups_kelvin.py workgroups.csv --apply # tatsächlich anlegen
Das Passwort wird interaktiv abgefragt (oder über die Umgebungsvariable
KELVIN_PASSWORD gesetzt, z.B. in einer CI-Pipeline).
"""
import argparse
import asyncio
import csv
import getpass
import logging
import os
import sys
from ucsschool.kelvin.client import (
KelvinClientError,
NoObject,
Session,
WorkGroup,
WorkGroupResource,
)
def read_rows(csv_file: str):
"""Liest die Tab-getrennte CSV-Datei ein und liefert bereinigte Zeilen."""
with open(csv_file, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter="\t")
for line_no, row in enumerate(reader, start=2): # Zeile 1 = Header
ou = (row.get("ou") or "").strip()
name = (row.get("name") or "").strip()
description = (row.get("description") or "").strip()
members_raw = (row.get("members") or "").strip()
if not ou or not name:
print(f"WARNUNG Zeile {line_no}: 'ou' oder 'name' fehlt, uebersprungen.", file=sys.stderr)
continue
members = [m.strip() for m in members_raw.split(";") if m.strip()]
yield line_no, ou, name, description, members
async def import_workgroups(csv_file: str, apply: bool, session: Session, create_share: bool = True) -> None:
wg_resource = WorkGroupResource(session=session)
created, skipped, failed = 0, 0, 0
for line_no, ou, name, description, members in read_rows(csv_file):
label = f"Zeile {line_no}: '{name}' (Schule: {ou})"
try:
exists = await wg_resource.exists(name=name, school=ou)
except KelvinClientError as exc:
print(f"FEHLER bei Existenzpruefung {label}: {exc}", file=sys.stderr)
failed += 1
continue
if exists:
print(f"{label} - existiert bereits, uebersprungen.")
skipped += 1
continue
print(f"{label} - wird angelegt (Mitglieder: {members or '-'})")
if not apply:
continue
wg = WorkGroup(
name=name,
school=ou,
description=description or None,
users=members, # leere Liste statt None - Kelvin-Client kann kein None iterieren
create_share=create_share,
session=session,
)
try:
await wg.save()
created += 1
except KelvinClientError as exc:
print(f"FEHLER beim Anlegen {label}: {exc}", file=sys.stderr)
failed += 1
print()
print("=== Fertig ===")
if apply:
print(f"Angelegt: {created}, uebersprungen (existierten bereits): {skipped}, Fehler: {failed}")
else:
print(f"Dry-Run: {skipped} existieren bereits, Rest wuerde mit --apply angelegt.")
print("Zum tatsaechlichen Anlegen erneut mit --apply aufrufen.")
async def main_async(args: argparse.Namespace) -> None:
# Die Kelvin-Client-Bibliothek loggt bei jedem exists()-Aufruf eine harmlose
# WARNING-Zeile, weil sie zuerst HEAD probiert (vom Server mit 405 abgelehnt,
# da nur GET/POST/PATCH/DELETE geroutet sind) und automatisch auf GET
# zurueckfaellt. Funktional unproblematisch, aber optisch stoerend - daher
# hier auf ERROR angehoben, damit nur echte Probleme durchkommen.
logging.getLogger("ucsschool.kelvin.client").setLevel(logging.ERROR)
host = args.host or os.environ.get("KELVIN_HOST")
username = args.username or os.environ.get("KELVIN_USER", "Administrator")
password = os.environ.get("KELVIN_PASSWORD") or getpass.getpass(
f"Kelvin-Passwort fuer {username}@{host}: "
)
if not host:
print("Fehler: --host oder KELVIN_HOST muss gesetzt sein.", file=sys.stderr)
sys.exit(1)
async with Session(username, password, host, verify=args.verify) as session:
await import_workgroups(args.csv_file, args.apply, session, create_share=not args.no_share)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("csv_file", help="Tab-getrennte CSV-Datei mit Arbeitsgruppen")
parser.add_argument("--apply", action="store_true", help="Aenderungen tatsaechlich ausfuehren (sonst Dry-Run)")
parser.add_argument(
"--no-share",
action="store_true",
help="Keine Samba-Freigabe automatisch anlegen (Workaround, falls das Schul-OU-Objekt "
"serverseitig nicht ueber die UDM REST API geladen werden kann, z.B. wegen "
"fehlerhafter Policy-Referenzen)",
)
parser.add_argument("--host", help="Kelvin-Host, z.B. master.ucs.local (Default: $KELVIN_HOST)")
parser.add_argument("--username", help="Kelvin-Benutzer (Default: $KELVIN_USER oder 'Administrator')")
parser.add_argument(
"--verify",
default=True,
help="SSL-Verifizierung: True, False, oder Pfad zu einer CA-Datei (Default: True)",
)
args = parser.parse_args()
if args.verify in ("false", "False"):
args.verify = False
print(f"=== UCS@school Kelvin Arbeitsgruppen-Import ===")
print(f"CSV-Datei : {args.csv_file}")
print(f"Modus : {'APPLY - Gruppen werden angelegt' if args.apply else 'DRY-RUN - es wird nichts veraendert'}")
print()
asyncio.run(main_async(args))
if __name__ == "__main__":
main()
Verification
Verify the created workgroup
After the import has completed successfully, the workgroup can be verified using the UDM command line tools.
Example:
udm groups/group list --filter "cn=Heisenberg-AG-Kunst"
Example output:
cn=Heisenberg-AG-Kunst
DN: cn=Heisenberg-AG-Kunst,cn=schueler,cn=groups,ou=Heisenberg,dc=example,dc=net
adGroupType: -2147483646
createTimestamp: 2026-07-06 12:41:21
creatorsName: cn=admin,dc=example,dc=net
description: Art workgroup
entryCSN: 20260706124121.162210Z#000000#000#000000
entryUUID: <redacted>
gidNumber: 5171
isOxGroup: OK
mailAddress: None
modifiersName: cn=admin,dc=example,dc=net
modifyTimestamp: 2026-07-06 12:41:21
name: Heisenberg-AG-Kunst
networkAccess: 0
nextcloudEnabled: 0
oxContext: None
sambaGroupType: 2
sambaRID: 11343
ucsschoolRole: workgroup:school:Heisenberg
univention-printer-default: None
univentionObjectIdentifier: <redacted>
univentionSourceIAM: None
users: uid=t.test,cn=teachers,cn=users,ou=Heisenberg,dc=example,dc=net
users: uid=a.mueller,cn=students,cn=users,ou=Heisenberg,dc=example,dc=net
vlanId: None
The most important attribute is:
ucsschoolRole: workgroup:school:Heisenberg
This confirms that the LDAP object was created as a UCS@school workgroup rather than a school class.
Verify the automatically created Samba share
For every imported workgroup, UCS@school automatically creates the corresponding Samba share.
Example:
udm shares/share list --filter "cn=Heisenberg-AG-Kunst"
The output should contain a share similar to the following:
root@ucs5primary:~/univention-support/import_workgroups/kelvin_rest_api# udm shares/share list --filter "cn=Heisenberg-AG-Kunst"
cn=Heisenberg-AG-Kunst
DN: cn=Heisenberg-AG-Kunst,cn=shares,ou=Heisenberg,dc=miro,dc=intranet
appendACL: (A;OICI;0x001f01ff;;;S-1-5-21-3450543618-4260802429-757627796-11197)
appendACL: (A;OICI;0x001f01ff;;;S-1-5-21-3450543618-4260802429-757627796-11343)
appendACL: (D;OICI;WOWD;;;S-1-5-21-3450543618-4260802429-757627796-11199)
createTimestamp: 2026-07-06 12:41:21
creatorsName: cn=admin,dc=miro,dc=intranet
description: None
directorymode: 0770
entryCSN: 20260706124121.659288Z#000000#000#000000
entryUUID: bc8f8976-0d83-1041-9a1d-05516da551ac
group: 5171
host: heisenberg.miro.intranet
modifiersName: cn=admin,dc=miro,dc=intranet
modifyTimestamp: 2026-07-06 12:41:21
name: Heisenberg-AG-Kunst
owner: 0
path: /home/Heisenberg/groups/Heisenberg-AG-Kunst
printablename: Heisenberg-AG-Kunst (heisenberg.miro.intranet)
sambaBlockSize: None
sambaBlockingLocks: 1
sambaBrowseable: 1
sambaCreateMode: 0770
sambaCscPolicy: manual
sambaDirectoryMode: 0770
sambaDirectorySecurityMode: 0777
sambaDosFilemode: 0
sambaFakeOplocks: 0
sambaForceCreateMode: 0
sambaForceDirectoryMode: 0
sambaForceDirectorySecurityMode: 0
sambaForceGroup: +Heisenberg-AG-Kunst
sambaForceSecurityMode: 0
sambaForceUser: None
sambaHideFiles: None
sambaHideUnreadable: 0
sambaInheritAcls: 1
sambaInheritOwner: 1
sambaInheritPermissions: 1
sambaLevel2Oplocks: 1
sambaLocking: 1
sambaMSDFSRoot: 0
sambaName: Heisenberg-AG-Kunst
sambaNtAclSupport: 1
sambaOplocks: 1
sambaPostexec: None
sambaPreexec: None
sambaPublic: 0
sambaSecurityMode: 0777
sambaStrictLocking: Auto
sambaWriteable: 1
ucsschoolRole: workgroup_share:school:Heisenberg
univentionObjectIdentifier: a995c302-ddd1-40f3-a3f5-f8fe537dc76d
If verification steps are successful, the workgroup has been imported correctly, the specified users have been added as members, and the associated Samba share has been created automatically by UCS@school.