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

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¶
Completed or read Part 2 — Transforming Fields.
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_grouporlocal_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:
oaa-runner scans the
DEPARTMENTcolumn across all rows.For each unique value (e.g.,
Engineering,Marketing), it creates a LocalGroup with that name.Each user is added to their department group.
The same process happens for ROLE → LocalRole.
Note:
DEPARTMENTappears in bothfield_mappingandgenerators;ROLEappears only ingenerators. The difference is intent:
DEPARTMENTinfield_mappingstores the raw column value as a custom property on each user (destination_field: department). It also drives thedept_groupsgenerator. You want both: a queryable attribute on the user, and group membership.
ROLEdrives therolesgenerator 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
ROLEstored as a custom property too, add it tofield_mappingwithcustom_type: STRING— there is no conflict.
About the
source_fieldwarning: Whensource_fieldnames a column that is not infield_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 —
ROLEis intentionally absent fromfield_mapping. The generator reads directly from the CSV stream and works correctly. To suppress the warning, addROLEtofield_mappingwithcustom_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.