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¶
OAA runner installed (see How to Install uv and How to Run Integrations Locally)
API documentation for your application
API credentials stored in a
.envfileBasic familiarity with
config.yamlstructure (see What is an OAA config file?)
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 |
|---|---|---|
|
Application-local user accounts |
|
|
Application-local groups |
|
|
Application-local roles |
|
|
Application resources (projects, files, etc.) |
|
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 |
|---|---|---|---|
|
Display name for the provider in Veza |
Yes |
|
|
Application type label |
No |
|
|
Application URL |
No |
|
|
Application domain |
No |
|
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 dataDataWrite— Write access to dataDataDelete— Delete access to dataDataCreate— Create access to dataMetadataRead— Read access to metadataNonData— 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 |
|---|---|---|
|
|
(required) |
|
Field to generate objects from |
(required) |
|
Jinja template for object name |
|
|
Jinja template for object unique_id |
|
|
Assign membership to the source object |
|
|
Skip empty/null values |
|
|
Field contains multiple values (e.g., comma-separated) |
|
|
Create the object if it doesn’t exist |
|
|
Optional field mapping for generated objects |
|
For more details, see How to Use Generators.
Step 8: Link Sources with Record Transforms¶
Use record_transforms_non_stream to link users to roles, groups, or resources after all sources have been fetched:
sources:
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
Implement the link function in your hooks module:
import petl
import logging
logger = logging.getLogger(__name__)
def link_role_users(source, all_sources=None, **kwargs):
"""Link roles to their assigned users."""
if not all_sources:
return
users_source = all_sources.get('app_users')
if not users_source:
logger.warning("No 'app_users' source found for linking")
return
# Build a lookup of user unique_ids
user_ids = set()
for record in petl.records(users_source.stream):
uid = record.get('unique_id')
if uid:
user_ids.add(uid)
# Add user assignments to each role
for record in source.stream:
assigned_users = record.get('assigned_user_ids', '')
if assigned_users:
valid_users = [
u.strip() for u in assigned_users.split(',')
if u.strip() in user_ids
]
record['users'] = valid_users
For more on linking, see How to Create User-to-Role Relationships.
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,LocalRoledestinationsTwo
Resourcetypes (Project and Workflow) in separate sourcespermission_to_veza_mapfor permission translationRole-to-user linking via
record_transforms_non_streamCustom 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¶
How to Create an HRIS Integration - Connect a workforce/HR system
How to Create an IDP Integration - Connect an identity provider
How to Configure Field Mappings - Map source fields to OAA fields
How to Use Generators - Auto-create groups and roles from field values
How to Use Multiple Datasources - Multi-source configurations
How to Create User-to-Role Relationships - Link entities across sources