Overrides and PowerShell
Building a connector, step 7 (LDAP track, 4 of 4). Previous: Install-editable values.
The definition covers what every install of a directory needs. When one operation needs something it cannot express, you have two ways out, and neither is a reason to build an HTTP connector instead:
- An override replaces one handler with TypeScript, and can call the engine's own handler inside it.
- A PowerShell script runs on the gateway, as a custom operation or in place of one handler. Use it for work LDAP cannot do, such as creating an Exchange mailbox or a home folder on a file share.
Both apply to SQL connectors in the same way.
Override one handler
The Handlers tab lists every handler the engine runs for your definition, such as user.create
or grant.member_of.add. Select Override beside one, and the builder writes a module at that
handler's path, which calls the engine's own handler unchanged:
import type { Override } from "../../types.gen.ts";
// Overrides the LDAP engine's user.create. `ctx.engineDefault` runs the engine's own handler for
// this key with the same context: change what goes in or what comes out, or replace it outright.
const handler: Override<"user.create"> = async (input, ctx) => {
return ctx.engineDefault(input);
};
export default handler;
The module's path comes from the handler key, with / between the parts:
| Handler key | Override file |
|---|---|
user.create | handlers/user/create.ts |
group.fetchRelationships | handlers/group/fetchRelationships.ts |
grant.member_of.add | handlers/grants/member_of/add.ts |
user.op.unlockAccount | handlers/user/op/unlockAccount.ts |
A flat name such as handlers/user.create.ts is refused. You can override only a handler the
definition enables: an override of user.delete on a connector without write.delete stops the
connector from building, and publishing is refused until you remove it.
ctx.engineDefault takes the same input the override received, so an override can adjust the input,
call the engine, and adjust the result. This one fills in displayName when provisioning sends none:
import type { Override } from "../../types.gen.ts";
const handler: Override<"user.create"> = async (input, ctx) => {
const displayName = input.displayName ?? [input.givenName, input.sn].filter(Boolean).join(" ");
return ctx.engineDefault({ ...input, displayName });
};
export default handler;
The engine writes only attributes the definition declares as writable, so displayName must be in the
account's attribute list.
When an override builds a DN or a filter, build it with ctx.ldap.dn and ctx.ldap.filter, which
escape every value: ctx.ldap.dn.child(parent, attribute, value), ctx.ldap.dn.escape(value),
ctx.ldap.filter.equals(attribute, value), ctx.ldap.filter.and(...) and the rest. Names from an HR
system and member DNs are data an attacker can influence; concatenating them into a filter is an
injection. Every method of ctx.ldap, and the result codes a refused call carries, is in the
Handler API.
An override that replaces a membership handler takes on the Grant write contract.
PowerShell scripts
A script is a file in the connector, scripts/<name>.ps1, where the name starts with a letter or digit
and uses letters, digits, _ and -, up to 64 characters. Add it on the Files tab. The definition
then binds it in one of two ways.
As a custom operation
"config": {
"properties": {
"homeRoot": {
"type": "string",
"title": "Home folder root",
"description": "The share home folders are created under, for example \\\\fs1.corp.northwind.example\\home.",
"default": ""
}
}
},
"custom_ops": {
"create-home-folder": {
"script": "create-home-folder",
"title": "Create home folder",
"description": "Creates the account's home folder under the install's home folder root.",
"params_schema": { "type": "object", "additionalProperties": false, "properties": {} },
"config": ["homeRoot"],
"timeoutMs": 120000
}
}
| Field | What it does |
|---|---|
script | The script's name, without scripts/ and .ps1. |
entity | Optional, and only the account entity: custom operations are actions on a person's account. |
title, description | What admins and workflows see. |
params_schema | JSON Schema for the operation's parameters. Owlie checks the parameters against it before the script runs. |
config | Install fields the script receives, as $Params.config. |
secrets | Parameters bound to gateway secrets (below). |
timeoutMs | How long the script may run, up to 900000 milliseconds. Two gateway limits cap it, both 60000 by default (below). |
This operation's timeoutMs of 120000 takes effect only when the gateway's operator raises its two
time limits to at least that (How a script runs). The operation's handler is <account entity>.op.<key>, here
user.op.create-home-folder. The script reads
its input from $Params, a hashtable: the operation's parameters, $Params.identifiers (the entry the
operation runs on, with its dn and identifier) and $Params.config. A caller cannot set
identifiers or config, so a script acts on the account it was run for, never on one a caller names:
# Creates the home folder of the account this operation runs on, under the install's home root,
# and gives the account Modify rights on it.
$ids = $Params.identifiers
if (-not ($ids -and $ids.objectGUID)) { throw 'create-home-folder runs on an account.' }
$root = [string]$Params.config.homeRoot
if (-not $root.StartsWith('\\')) { throw 'The install has no home folder root.' }
$user = Get-ADUser -Identity ([guid]$ids.objectGUID)
$path = Join-Path $root $user.SamAccountName
New-Item -ItemType Directory -Path $path -Force | Out-Null
$acl = Get-Acl -Path $path
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($user.SID, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.AddAccessRule($rule)
Set-Acl -Path $path -AclObject $acl
[pscustomobject]@{ path = $path } | ConvertTo-Json -Compress
This script needs the ActiveDirectory PowerShell module on the gateway host, and the gateway's own account needs the rights to create the folder and set its permissions.
In place of one handler
handlers.<key> replaces the engine's handler for a key the definition enables, with the fields
script, config, secrets and timeoutMs:
"handlers": {
"user.disable": { "script": "disable-account" }
}
The script must write what the engine's handler would return, and what it reads as $Params depends
on the key:
fetchOne,create,fetchAll,fetchAllSince,delete,enable,disable, a Grant kind's add and remove, andconnection.testConnection/connection.validateConfigall take one value, which the script reads as$Params.payload, beside$Params.identifiersfor the entry the operation runs on:nullwhen the operation has none, such ascreate,fetchAll,fetchAllSince, a Grant write, or either connection operation, which has no entry at all, only the install being tested or validated.updatetakes the entry and the change, so the script reads$Params.identifiersand$Params.input.fetchRelationshipstakes the external id of the entry whose relationships are read (the group, for a membership grant) and the page request (its cursor and page size), so the script reads$Params.externalIdand$Params.options. There is no$Params.identifiershere: the operation is not about one entry, only about what points at it.
A script replacing fetchOne, fetchAll, fetchAllSince or fetchRelationships stands in for a
read, so Owlie checks its output before using it: a page (fetchAll, fetchAllSince,
fetchRelationships) must be { records, nextCursor, hasMore }, with nextCursor set whenever
hasMore is true, and fetchOne must be { record }, record being the entry or null when there
is none. A script that returns something else fails the operation with a message that says so,
instead of passing the bad result on to a sync or a lookup.
A script cannot report "already present" or "already absent" for a Grant write; override the handler in TypeScript when you need to. A binding for a key the definition does not enable is refused, and so is one for a read-only operation such as directory discovery: there is no PowerShell broker in that context, so a script bound there would fail every time it ran.
How a script runs
- The gateway runs only the text of the script in the build being run, checked by its digest: a published version for installs, or your draft during a test run. Changing a script on an install means publishing a new version. A script and its parameters may each be up to 256 KiB.
- The gateway starts the script with
$ErrorActionPreference = 'Stop'and$Paramsalready set, from JSON it passes on standard input, never on the command line. Because that preamble comes first, a script cannot begin withparam(...),usingor#requires. - The script writes exactly one JSON value to standard output, which becomes the operation's result. A non-zero exit, no output, or output that is not JSON fails the operation with the end of the script's error output.
- Publishing refuses a script that evaluates text or starts another program:
Invoke-Expression,[ScriptBlock]::Create,Add-Type,&or.before a variable, expression or string, dot-sourcing a file, startingpwsh,powershellorcmd /c,Start-Process,Invoke-CommandandInvoke-Item. A script's parameters come from identity data that people can influence, and must stay data. - A script may run 60 seconds by default.
timeoutMsasks for more, but the gateway caps it twice: at the connector'spowershellTimeoutMs, and atcustomConnectorLimits.timeoutMs, its limit on a whole operation. Both default to 60000, so atimeoutMsabove 60000 has no effect until the gateway's operator raises both (gateway settings).powershellTimeoutMsgoes up to 900000 (15 minutes). A script that runs out of time is stopped with every process it started, and the operation is not retried, because the script may already have changed something.
Secrets
secrets binds a parameter to a secret the gateway holds, so the script can use a credential that
never passes through Owlie's cloud or the connector's own code:
"secrets": { "fileServerPassword": "NORTHWIND_FS_PASSWORD" }
The script reads the value as $Params.fileServerPassword. The gateway resolves the name from its own
secrets, and only when its operator has granted that name to this connector. A caller's parameters can
never name a secret. In a draft test the gateway resolves none of its secrets, so an operation whose
script binds one runs only from a published, installed version.
A parameter name cannot be one Owlie already fills for that binding, and publishing refuses one that
tries. For a custom operation that is identifiers or config; its parameters never carry anything
else Owlie fills, so nothing else is off limits (that also means a custom operation's params_schema
cannot declare a property named identifiers or config). For a binding in place of one handler,
config is always off limits too, plus whatever else that handler's shape fills: payload and
identifiers for most handlers, identifiers and input for update, or externalId and
options for fetchRelationships.
The operator's gate, and what it means
A gateway runs a connector's scripts only when its operator sets allowPowershell for that connector.
Without it, every script operation fails with a message naming the setting.
A script runs as the gateway's operating-system account, and whatever it writes to standard output
returns to Owlie as the operation's result, so a script must never print a secret. The gateway logs
the full text of each new script the first time it runs it, so its operator can read what runs.
Private-network gateways explains what
allowPowershell gives a connector and how operators should grant it.
Next: Test your connector