How-to: Import workgroups into UCS@school using the Kelvin REST API

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-pip
  • python3 -m venv venv
  • source venv/bin/activate
  • pip install --upgrade pip
  • pip 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_PASSWORD environment 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.