Learning the OAA Runner Framework: Part 3 — Modeling a Custom Application

Calvin wearing a tiny hard hat stands next to an elaborate hand-drawn architectural blueprint pinned to a wall, pointing at it with a ruler. The blueprint shows interconnected boxes labeled "USER", "GROUP", "ROLE". Hobbes holds the blueprint steady from the other side.

This is Part 3 of the Learning the OAA Runner Framework series. It builds on Part 2 — Transforming Fields.

What you’ll learn: Use oaa-runner’s generators feature to automatically create groups and roles from CSV column values and assign users to them — without writing any Python code or maintaining a separate groups/roles CSV.

Prerequisites

Setup

Create your working directory:

mkdir -p 03-csv-custom-app/data
cd 03-csv-custom-app

Copy the following content into a new file at data/users.csv — this adds a ROLE column to the Part 2 data:

USER_ID,EMAIL,FIRST_NAME,LAST_NAME,IS_ACTIVE,HIRE_DATE,DEPARTMENT,ROLE
1001,alice@example.com,Alice,Anderson,yes,2020-01-15,Engineering,Admin
1002,bob@example.com,Bob,Baker,1,2019-03-22,Engineering,Developer
1003,carol@example.com,Carol,Chen,true,2021-06-01,Marketing,Viewer

The goal: for each unique value in DEPARTMENT, create a LocalGroup and add the user to it. For each unique value in ROLE, create a LocalRole and assign the user to it. All from the single users CSV, with no additional files.

Copy the complete final config from Part 2 — Transforming Fields into a new file at config.yaml. You’ll make two changes to it below.


Step 1: Switch source type from csv_push to csv

In the users source, change source_type:

Before:

    source_type: csv_push
    csv_filepath: data/users.csv
    csv_provider_id: "!env"

After:

    source_type: csv
    csv_filepath: data/users.csv

csv_push uploads the raw CSV to Veza’s CSV ingest endpoint. csv reads the file locally and processes it through oaa-runner’s full pipeline, which is required for generators. When using csv, remove csv_provider_id — it’s only used by csv_push.


Step 2: Add generators

Below the field_mapping block, still inside the users source, add a generators block:

    generators:
      dept_groups:
        generator_type: local_group
        source_field: DEPARTMENT
        add_membership: true
      roles:
        generator_type: local_role
        source_field: ROLE
        add_membership: true

For each generator:

  • generator_type — the OAA entity to create (local_group or local_role).

  • source_field — the column to scan for unique values. One entity is created per unique value.

  • add_membership: true — automatically adds the current user to the created entity.

With dept_groups as configured:

  1. oaa-runner scans the DEPARTMENT column across all rows.

  2. For each unique value (e.g., Engineering, Marketing), it creates a LocalGroup with that name.

  3. Each user is added to their department group.

The same process happens for ROLE → LocalRole.

Note: DEPARTMENT appears in both field_mapping and generators; ROLE appears only in generators. The difference is intent:

  • DEPARTMENT in field_mapping stores the raw column value as a custom property on each user (destination_field: department). It also drives the dept_groups generator. You want both: a queryable attribute on the user, and group membership.

  • ROLE drives the roles generator but is not mapped to any user field. There is no built-in OAA user property for role name, and storing it as a custom property would be redundant with the LocalRole relationship. The generator is enough.

If you want ROLE stored as a custom property too, add it to field_mapping with custom_type: STRING — there is no conflict.

About the source_field warning: When source_field names a column that is not in field_mapping, oaa-runner logs:

WARNING - generator [roles] source_field "ROLE" is not in field_mapping for source "users".
The field will be read directly from the raw data stream.

This is expected here — ROLE is intentionally absent from field_mapping. The generator reads directly from the CSV stream and works correctly. To suppress the warning, add ROLE to field_mapping with custom_type: STRING.


Complete config

version: v2
log_level: INFO

app:
  name: Example App - CSV Custom App
  application_type: Custom App

sources:
  users:
    destination: LocalUser
    source_type: csv
    csv_filepath: data/users.csv
    field_mapping:
      USER_ID:
        destination_field: unique_id
      EMAIL:
        destination_field: email
        transforms:
          - name: lowercase
      FULL_NAME:
        destination_field: name
        transforms:
          - name: jinja_render
            options:
              template: "{{ row.FIRST_NAME }} {{ row.LAST_NAME }}"
      FIRST_NAME:
        destination_field: given_name
        custom_type: STRING
      LAST_NAME:
        destination_field: family_name
        custom_type: STRING
      IS_ACTIVE:
        destination_field: is_active
        transforms:
          - name: validate_boolean
      HIRE_DATE:
        destination_field: hire_date
        transforms:
          - name: date_convert
      DEPARTMENT:
        destination_field: department
        custom_type: STRING
    generators:
      dept_groups:
        generator_type: local_group
        source_field: DEPARTMENT
        add_membership: true
      roles:
        generator_type: local_role
        source_field: ROLE
        add_membership: true

Run it

uv tool run --from oaa-runner oaa app run --config-file=config.yaml --no-push

You should see output ending with:

INFO - Skipping push to Veza (no_push=True)
status: true
report: []

Generators do not emit per-entity log lines — the status: true result confirms they ran successfully. In Veza, you’ll see the two department groups (Engineering, Marketing) and three roles (Admin, Developer, Viewer) with users correctly assigned to each.


What’s next

Generators work well when the relationship is “create an entity from this column and add this user to it.” Some relationships are more complex — for example, linking records across two different CSV files, or assigning a user multiple roles from a separate assignments table. These require hooks.

Before writing a hook, read the explanation of when config.yaml can’t solve the problem: Exhaust config.yaml before writing a hook.

Next: Part 4 — Writing Your First Hook

See also