How to Create a Custom App Integration

TL;DR

Build an OAA integration that connects any application managing its own users, groups, roles, or resources using destination_type: CUSTOM_APP (the default). This guide covers LocalUser, LocalGroup, LocalRole, and Resource destinations, permission mapping, generators, multi-source configurations, and end-to-end setup.

Prerequisites

When to Use CUSTOM_APP

Use destination_type: CUSTOM_APP (or omit destination_type entirely — it is the default) when your source system manages its own users, groups, roles, or resources. This is the most flexible runner type and covers the widest range of integrations, including:

  • SaaS applications with local user accounts (Jira, Salesforce, etc.)

  • Internal tools with role-based access control

  • Databases or platforms with resource-level permissions

  • Any system that is not an HR system of record or a dedicated identity provider

For a full comparison of all destination types, see What Are the Runner Types?.

Available Destination Types

CUSTOM_APP integrations support four destination object types:

Destination

Description

Required Fields

LocalUser

Application-local user accounts

name, unique_id

LocalGroup

Application-local groups

name, unique_id

LocalRole

Application-local roles

name, unique_id

Resource

Application resources (projects, files, etc.)

name, unique_id + source-level resource_type

Steps

Step 1: Set Up the Custom App Configuration

Create a config.yaml. If you omit destination_type, it defaults to CUSTOM_APP:

version: v2
log_level: INFO
# destination_type: CUSTOM_APP  # optional — this is the default

app:
  name: My Application
  application_type: Custom App

module_directory: ./modules

app fields for CUSTOM_APP:

Field

Description

Required

Example

name

Display name for the provider in Veza

Yes

My Application

application_type

Application type label

No

Custom App

url

Application URL

No

https://app.example.com

domain

Application domain

No

app.example.com

Step 2: Configure a Connection

Define a custom connection that fetches data from your application API:

connections:
  app_api:
    connection_type: custom
    custom_module: app_client
    required_env:
      - APP_API_KEY
    params:
      base_url: https://app.example.com/api/v1

Store your API key in .env:

# .env
APP_API_KEY=your_api_key_here

For more details, see How to Add a REST API Source and How to Authenticate with API Keys.

Step 3: Define a LocalUser Source

Map your application’s user data to LocalUser objects:

sources:
  app_users:
    destination: LocalUser
    source_type: custom
    connection: app_api
    params:
      resource: /users
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      email:
        destination_field: email
        transforms:
          - name: lowercase
      name:
        destination_field: name
        transforms:
          - name: jinja_render
            options:
              template: "{{ row.display_name }}"
      first_name:
        destination_field: given_name
        custom_type: STRING
      last_name:
        destination_field: family_name
        custom_type: STRING
      title:
        destination_field: title
        custom_type: STRING
      department:
        destination_field: department
        custom_type: STRING
      status:
        destination_field: is_active
        transforms:
          - name: jinja_render
            options:
              template: "{{ 'true' if row.status == 'active' else 'false' }}"
          - name: validate_boolean
      created_at:
        destination_field: created_at
        transforms:
          - name: date_convert
      last_login:
        destination_field: last_login_at
        transforms:
          - name: date_convert

Step 4: Define LocalGroup and LocalRole Sources

Add group and role sources as needed:

sources:
  app_groups:
    destination: LocalGroup
    source_type: custom
    connection: app_api
    params:
      resource: /groups
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      is_admin:
        destination_field: is_admin
        custom_type: BOOLEAN

  app_roles:
    destination: LocalRole
    source_type: custom
    connection: app_api
    params:
      resource: /roles
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
    record_transforms_non_stream:
      - link_role_users

Step 5: Define Resource Sources

Resource destinations require a source-level resource_type field. You can define multiple Resource sources with different types:

sources:
  app_projects:
    destination: Resource
    resource_type: Project
    source_type: custom
    connection: app_api
    params:
      resource: /projects
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      status:
        destination_field: status
        custom_type: STRING
      priority:
        destination_field: priority
        custom_type: STRING
      is_confidential:
        destination_field: is_confidential
        custom_type: BOOLEAN

  app_workflows:
    destination: Resource
    resource_type: Workflow
    source_type: custom
    connection: app_api
    params:
      resource: /workflows
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      status:
        destination_field: status
        custom_type: STRING
      is_automated:
        destination_field: is_automated
        custom_type: BOOLEAN

Step 6: Configure Permission Mapping

Use permission_to_veza_map to translate your application’s permission names into Veza’s standard permission types:

permission_to_veza_map:
  read:
    permissions:
      - DataRead
  write:
    permissions:
      - DataWrite
  delete:
    permissions:
      - DataDelete
  create:
    permissions:
      - DataCreate
  update:
    permissions:
      - DataWrite
  list:
    permissions:
      - MetadataRead
  admin:
    permissions:
      - DataRead
      - DataWrite
      - DataDelete
      - DataCreate
      - NonData

Each entry maps a source permission name to a list of Veza permission types. When a LocalRole source has do_perm_mapping: true, the runner automatically registers custom permissions and resolves them through this map.

Available Veza permission types:

  • DataRead — Read access to data

  • DataWrite — Write access to data

  • DataDelete — Delete access to data

  • DataCreate — Create access to data

  • MetadataRead — Read access to metadata

  • NonData — Non-data permission (administrative, etc.)

Step 7: Use Generators for Dynamic Object Creation

Generators automatically create LocalGroup or LocalRole objects from field values in another source. This is useful when groups/roles are implied by data fields rather than being a separate API endpoint:

sources:
  app_users:
    destination: LocalUser
    source_type: custom
    connection: app_api
    params:
      resource: /users
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      department:
        destination_field: department
        custom_type: STRING
    generators:
      # Auto-create a LocalGroup for each unique department value
      dept_groups:
        generator_type: local_group
        source_field: department
        name_template: "{{ value }}"
        unique_id_template: "dept_{{ value }}"
        add_membership: true
        skip_empty: true

      # Auto-create a LocalRole for each unique user_type value
      type_roles:
        generator_type: local_role
        source_field: user_type
        name_template: "{{ value }}"
        unique_id_template: "role_{{ value }}"
        add_membership: true
        create_if_missing: true

Generator fields:

Field

Description

Default

generator_type

local_group or local_role

(required)

source_field

Field to generate objects from

(required)

name_template

Jinja template for object name

{{ value }}

unique_id_template

Jinja template for object unique_id

{{ value }}

add_membership

Assign membership to the source object

true

skip_empty

Skip empty/null values

true

multi_value

Field contains multiple values (e.g., comma-separated)

false

create_if_missing

Create the object if it doesn’t exist

true

field_mapping

Optional field mapping for generated objects

null

For more details, see How to Use Generators.

Step 9: Write the Fetch Module

Create modules/app_client.py with a fetch() function that returns a lazy_dict_table for memory efficiency:

import requests
import logging
from oaa.modules.lazy_dicts import lazy_dict_table

logger = logging.getLogger(__name__)


class AppAPIClient:
    def __init__(self, connection=None, source=None):
        params = connection.params if connection else {}
        self.base_url = params.get('base_url')
        self.api_key = params.get('api_key')
        self.session = requests.Session()
        self.session.headers.update({
            'Authorization': f'Bearer {self.api_key}'
        })

    def fetch_paginated(self, resource, resource_key='data', page_size=100):
        """Fetch all records from a paginated API endpoint."""
        offset = 0

        while True:
            url = f"{self.base_url}{resource}"
            params = {
                'offset': offset,
                'limit': page_size
            }

            response = self.session.get(url, params=params, timeout=30)
            response.raise_for_status()
            data = response.json()
            items = data.get(resource_key, [])

            if not items:
                break

            for item in items:
                yield item

            offset += page_size
            logger.info(f"Fetched {offset} records from {resource}")


def fetch(connection=None, source=None, **kwargs):
    """
    Fetch the data. This is the function that is called by oaa.

    :param connection: Connection configuration from config.yaml
    :param source: Source configuration from config.yaml
    :returns: PETL table using lazy_dict_table for memory efficiency
    """
    api = AppAPIClient(connection=connection, source=source)
    resource_path = source.params.get('resource', '')
    resource_key = source.params.get('resource_key', 'data')
    results = api.fetch_paginated(resource_path, resource_key=resource_key)
    return lazy_dict_table(results)

For more details, see How to Write a Fetch Function and How to Handle API Pagination.

Step 10: Validate and Run

Validate your configuration:

uv tool run --from oaa-runner oaa app validate --config=config.yaml

Run a dry run (no push to Veza):

uv tool run --from oaa-runner oaa app run --config=config.yaml --dry_run=True --save-json=True

Inspect output.json to verify all destination objects are populated correctly:

# Check local user count
jq '.local_users | length' output.json

# Check local groups
jq '.local_groups | length' output.json

# Check local roles
jq '.local_roles | length' output.json

# Check resources (by resource type)
jq '.resources | length' output.json

# Check first user
jq '.local_users[0]' output.json

Run with push to Veza:

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

End-to-End config.yaml Example

version: v2
log_level: INFO

app:
  name: BongaWonga App
  application_type: Custom App

module_directory: ./modules
logic_modules:
  - bw_hooks

connections:
  bw_app:
    connection_type: custom
    custom_module: app_bw
    required_env:
      - BW_APP_API_KEY
    params:
      base_url: https://app.bongawonga.com/api/v1

sources:
  app_users:
    destination: LocalUser
    source_type: custom
    connection: bw_app
    params:
      resource: /users
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      email:
        destination_field: email
        transforms:
          - name: lowercase
      name:
        destination_field: name
        transforms:
          - name: jinja_render
            options:
              template: "{{ row.display_name }}"
      first_name:
        destination_field: given_name
        custom_type: STRING
      last_name:
        destination_field: family_name
        custom_type: STRING
      title:
        destination_field: title
        custom_type: STRING
      department:
        destination_field: department
        custom_type: STRING
      status:
        destination_field: is_active
        transforms:
          - name: jinja_render
            options:
              template: "{{ 'true' if row.status == 'active' else 'false' }}"
          - name: validate_boolean
      created_at:
        destination_field: created_at
        transforms:
          - name: date_convert
      last_login:
        destination_field: last_login_at
        transforms:
          - name: date_convert

  app_groups:
    destination: LocalGroup
    source_type: custom
    connection: bw_app
    params:
      resource: /groups
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      is_admin:
        destination_field: is_admin
        custom_type: BOOLEAN

  app_roles:
    destination: LocalRole
    source_type: custom
    connection: bw_app
    params:
      resource: /roles
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
    record_transforms_non_stream:
      - link_role_users

  app_projects:
    destination: Resource
    resource_type: Project
    source_type: custom
    connection: bw_app
    params:
      resource: /projects
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      status:
        destination_field: status
        custom_type: STRING
      priority:
        destination_field: priority
        custom_type: STRING
      is_confidential:
        destination_field: is_confidential
        custom_type: BOOLEAN

  app_workflows:
    destination: Resource
    resource_type: Workflow
    source_type: custom
    connection: bw_app
    params:
      resource: /workflows
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      status:
        destination_field: status
        custom_type: STRING
      is_automated:
        destination_field: is_automated
        custom_type: BOOLEAN

permission_to_veza_map:
  read:
    permissions:
      - DataRead
  write:
    permissions:
      - DataWrite
  delete:
    permissions:
      - DataDelete
  create:
    permissions:
      - DataCreate
  update:
    permissions:
      - DataWrite
  list:
    permissions:
      - MetadataRead
  admin:
    permissions:
      - DataRead
      - DataWrite
      - DataDelete
      - DataCreate
      - NonData

A complete working example is available at docs/howtos/examples/06-multi-source-api/config_customapp.yaml.

Multi-Source Configurations

CUSTOM_APP integrations commonly use multiple sources fetching from different API endpoints. Each source can use the same or different connections:

sources:
  # Users from the main API
  app_users:
    destination: LocalUser
    source_type: custom
    connection: app_api
    params:
      resource: /users

  # Roles from a separate endpoint
  app_roles:
    destination: LocalRole
    source_type: custom
    connection: app_api
    params:
      resource: /roles

  # Resources from a different connection entirely
  app_resources:
    destination: Resource
    resource_type: Document
    source_type: custom
    connection: resource_api  # different connection
    params:
      resource: /documents

For more on multiple connections, see How to Use Multiple Datasources.

Examples

Real Integration: BongaWonga App

The BongaWonga training series includes a full CUSTOM_APP integration with:

  • LocalUser, LocalGroup, LocalRole destinations

  • Two Resource types (Project and Workflow) in separate sources

  • permission_to_veza_map for permission translation

  • Role-to-user linking via record_transforms_non_stream

  • Custom properties for various application-specific fields

See the example config at docs/howtos/examples/06-multi-source-api/config_customapp.yaml.

Real Integration: ThousandEyes

From netapp/thousandeyes/config.yaml — a simpler CUSTOM_APP integration with a single LocalUser source:

sources:
  users:
    destination: LocalUser
    source_type: custom
    connection: api
    params:
      resource: users
    field_mapping:
      email:
        destination_field: email
      name:
        destination_field: name
      uid:
        destination_field: unique_id

Next Steps