Vertex protocol
Entrosity Vertex has no connector of its own. It runs every directory
request as a job on the tenant's Entrosity Axis site connector, which
must be installed on a domain controller. The jobs travel through the
Axis backend and the normal agent and connector protocol;
the connector's secrets requests and large results travel back through
Axis to Vertex. The Go types in entrosity-shared-go/proto/vertex are the
single source of truth; this page describes intent and behaviour.
Like the rest of the protocol, these types only grow: fields are added, never renamed or repurposed, so connectors in the field keep working.
Capability and job types
A connector that can run Vertex jobs announces the capability vertex
in its hello (Windows builds of entrosity-axis-connector). Vertex only
offers connectors with it (supports_vertex).
| Job type | Operations | Connector lane |
|---|---|---|
vertex.op | Every operation of a write class | vertex: one at a time, in order |
vertex.read | Operations of class read | vertex-read: two in parallel, next to a running change |
Both carry the same payload, an OpJob:
| Field | Meaning |
|---|---|
operation_id | Vertex's operation ID: names the job's secrets and its chunks. |
op | The operation (Operations). |
managed_ous | The tenant's managed OU DNs (at most 100). Writes outside them are refused; the guard compares them with the accepted list. |
server | Optional domain controller host name (default: the local one). |
params | The operation's parameters (JSON; types in proto/vertex/params.go). |
expires_at | The connector does not start a write after this time (expired). |
The payload never contains secrets. Vertex queues reads with priority 10 and writes with priority 5; a write expires after 1 hour (an import batch after 6), and runs for at most 10 minutes (reads 30 minutes, import batches an hour). Axis bounds them at 24 hours and 2 hours.
Operations
op | Class | Delete | Passwords | params |
|---|---|---|---|---|
test | read | none → result test (TestResult) | ||
sync | read | SyncParams {kinds?, user_attributes?, search_base?} → chunks objects, gpos | ||
schema | read | SchemaParams {classes?} → chunks schema | ||
object.get | read | GetParams {target, attributes?} → object with writable | ||
gpo.report | read | GPORef {id} → chunks report | ||
gpo.backup | read | GPORef {id} → backup_id | ||
user.create | user | ✓ | UserCreateParams {ou, name, sam_account_name, user_principal_name?, given_name?, surname?, display_name?, enabled, must_change_password?, password_never_expires?, cannot_change_password?, password_key?, attrs?, groups?} | |
object.update | of the target | UpdateParams {target, set?, add?, remove?, clear?} | ||
user.password | user | ✓ | PasswordParams {target, password_key, must_change, unlock?} | |
user.unlock | user | TargetParams {target} | ||
user.enable | user | EnableParams {target, enabled} | ||
object.move | of the target | MoveParams {target, target_ou} | ||
object.rename | of the target | RenameParams {target, new_name} | ||
object.delete | of the target | ✓ | DeleteParams {target, recursive?} (OUs: recursive) | |
group.create | group | GroupCreateParams {ou, name, sam_account_name?, scope, category, description?, attrs?} | ||
member.change | group | MemberParams {group, add?, remove?} (DNs, at most 500 each) | ||
ou.create | ou | OUCreateParams {parent_dn, name, description?, protect_from_deletion} | ||
pso.set | pso | PSOParams {target?, name?, settings?, apply_to?, unapply?} (no target: create) | ||
gpo.create | gpo | GPOCreateParams {name, comment?, link_to?} | ||
gpo.update | gpo | GPOUpdateParams {id, name?, status?, comment?} | ||
gpo.delete | gpo | ✓ | GPORef {id} | |
gpo.link | gpo | GPOLinkParams {id, target_dn, action: link|unlink|set, enabled?, enforced?, order?} | ||
gpo.registry | gpo | GPORegistryParams {id, set?, remove?} (RegistryValue {scope, key, value_name, type?, value?}, at most 100) | ||
gpo.permission | gpo | GPOPermissionParams {id, trustee, trustee_type, level, replace?} | ||
bulk.users | user | ✓ | BulkParams {mode: create|update|upsert, rows: [BulkRow]} (1–200 rows) → chunks rows |
A Target is {guid, kind, dn?}: the connector resolves the object
by objectGUID (the DN is informational); kind is user, group,
ou or pso. The object.* operations take the class of their
target's kind (user, group, ou, pso).
Writes counted against the change limits and the connector's cap: one
per operation, except member.change (one per member added or removed),
gpo.registry (one per value) and bulk.users (one per row).
Attrs are {ldapName: [values]}; binary values are b64:<base64>.
Names on DeniedAttributes (proto/vertex/names.go, mirrored in the
script) are refused. At most 1,000 values per attribute, 64 KiB per value.
Secrets
The job's secrets, the AD account (credential: {username, password})
and the passwords of the operation (passwords: {key: password}, keyed by
password_key or the bulk row's key), are fetched by the connector
once per job, after its guard allowed the job:
- Connector → Axis:
POST /api/connector/v1/vertex/jobs/{jobID}/secrets {"operation_id": "…"}with the connector key. - Axis checks that the job is a live Vertex job of this connector
(its job, type
vertex.op/vertex.read, not finished, not expired, the sameoperation_id); otherwise403 job_not_live. - Axis → Vertex:
POST /internal/v1/axis/secrets {tenant_id, connector_id, job_id, operation_id}. - Vertex checks that the operation exists for that job and connector and
is not finished or expired, that the tenant is still configured with
that connector, that the write switches still allow the class (and
delete), that the author still holds the permission for the
operation (an active user with the role;
author_revoked), and that the secrets were not fetched before (secrets_used). Only then does it decrypt and answerSecrets; the operation's sealed passwords are deleted as they are handed out. - Axis answers the connector with
Cache-Control: no-store.
A refusal reaches the connector as 403 vertex_refused with Vertex's
reason; the connector fails the job with unauthorized and the reason in
the message. Nothing is written.
Chunks
Large results are sent while the job runs: POST /api/connector/v1/vertex/jobs/{jobID}/chunks with a Chunk
{operation_id, seq, kind, items}. seq starts at 0 and has no gaps;
Vertex applies a repeated chunk once. Axis checks the job as for secrets
and forwards {tenant_id, connector_id, job_id, chunk} to
POST /internal/v1/axis/chunks (204).
kind | items | Sent by |
|---|---|---|
objects | [Object] (250 per chunk) | sync |
gpos | [GPO] | sync |
schema | [AttributeSchema] | schema |
rows | [RowResult {key, status, error_code?, error?, object?}] (25 per chunk) | bulk.users |
report | [{xml}] (parts of 200,000 characters) | gpo.report |
Results
job.result.result of a succeeded job is an OpResult: object
(the changed object with its attributes, and writable from
allowedAttributesEffective), gpo, deleted (a GUID), backup_id,
test or counts ({kind: items} for sync, schema, bulk and registry).
Bulk row statuses: created, updated, unchanged, failed,
skipped. An Object has guid, dn, kind, sid?, attrs,
writable? and protected (adminCount=1, isCriticalSystemObject or
a relative ID below 1000).
When Axis records the result of a Vertex job it posts
POST /internal/v1/axis/jobs/finished {tenant_id, job_id} to Vertex (a
hint, best effort); Vertex also polls its open operations every 2
seconds and applies each result once.
The bridge between Vertex and Axis
Two internal listeners, never routed publicly, authenticated in both
directions by one bearer token (VERTEX_AXIS_TOKEN in Vertex,
RMM_VERTEX_AXIS_TOKEN in Axis, at least 32 characters):
| Direction | Endpoint | Purpose |
|---|---|---|
Vertex → Axis (RMM_INTERNAL_ADDR, :8089) | GET /internal/v1/tenants/{tenant}/connectors | The tenant's connectors that are not revoked (AxisConnector: id, name, hostname, domain, version, status, capabilities, last seen). |
POST /internal/v1/tenants/{tenant}/jobs | Queue a job: AxisCreateJob {connector_id, job: OpJob, timeout_seconds, expires_in_seconds, priority} → 201 AxisJob. The job type follows from op. | |
GET /internal/v1/tenants/{tenant}/jobs/{job} | The job's state (AxisJob: status, progress, error, result). Only Vertex jobs. | |
POST /internal/v1/tenants/{tenant}/jobs/{job}/cancel | Cancel it (and tell the connector). | |
Axis → Vertex (VERTEX_INTERNAL_ADDR, :8088) | POST /internal/v1/axis/secrets | BridgeSecretsRequest → Secrets. |
POST /internal/v1/axis/chunks | BridgeChunk → 204. | |
POST /internal/v1/axis/jobs/finished | BridgeJobFinished → 204. |
Axis's connector endpoints POST /vertex/jobs/{jobID}/secrets and
/chunks exist only when Vertex is configured in Axis
(RMM_VERTEX_INTERNAL_URL). Axis stores no Vertex secrets. When Vertex
does not answer, Axis answers 503 vertex_unavailable.
On the connector
For every vertex.op job the connector:
- validates the
OpJob(invalid_payload); - for writes: refuses an expired job (
expired), records the managed OUs in its local guard, and checks the guard: accepted (guard_pending), the class and delete switched on (guard_denied), the per-minute cap (rate_limited); - fetches the secrets (
unauthorizedwhen refused); - starts Windows PowerShell 5.1 (
powershell.exe -NoProfile -NonInteractive -EncodedCommand <bootstrap>) and writes the input, with the embedded script, base64-encoded to its standard input; no value is ever placed in a command line or interpolated into code; - the script opens a PowerShell session to the same machine
(
New-PSSession -ComputerName $env:COMPUTERNAME) with the AD account's credential and runs the operation there with theActiveDirectoryandGroupPolicymodules, checking the managed OUs, protection and the denied attributes again before it writes; - turns the script's output lines into progress, chunks and the result.
The script writes one JSON object per line: {"t":"progress","pct","msg"},
{"t":"chunk","kind","items"}, {"t":"result","result"},
{"t":"error","code","msg"} and {"t":"log","msg"} (kept in the job's
output tail). GPOs are backed up into %ProgramData%\Entrosity\Vertex GPO Backups before gpo.update, gpo.registry and gpo.delete (required
for the delete). The default domain policies are refused (protected).
Error codes
Job error codes (job.result.error_code, the operation's error_code):
| Code | Meaning |
|---|---|
guard_pending | The local guard is not accepted, the job's managed OUs differ from the accepted ones, or the guard file is not trusted. |
guard_denied | The local guard does not allow this class, or deletes. |
rate_limited | The connector's local cap of writes per minute (default 1,000, at most 2,000). |
expired | The job arrived after expires_at. |
unauthorized | Vertex refused the secrets request (the message says why). |
out_of_scope | The object or target is outside the managed OUs (a PSO: outside the Password Settings Container). |
protected | A privileged or built-in object, or a default domain policy. |
denied_attribute | An attribute on the deny list. |
not_found | The object does not exist. |
already_exists | An object with this name exists. |
password_policy | The password does not meet the policy. |
access_denied | The AD account lacks the right (or may not open the PowerShell session: add it to Remote Management Users). |
logon_failed | The AD account's credentials were refused. |
missing_module | The ActiveDirectory or GroupPolicy module is missing. |
ad_error | Any other AD or PowerShell error, or results that could not be delivered. |
invalid_payload, unsupported, cancelled | As for every job (Job error codes). |
What they mean for users: Vertex troubleshooting.
Limits
| Limit | Value |
|---|---|
| Managed OUs | 100 |
Rows per bulk.users job | 200 |
Members per member.change (add, remove) | 500 each |
| Connector writes per minute (local guard) | 1,000 by default, 1 to 2,000 |
Registry values per gpo.registry | 100 |
| Values per attribute / value size | 1,000 / 64 KiB |
| DN length | 2,048 |
| Password length | 256 |
| Items per chunk | 500 |
| Create-job request body (Axis) | 4 MiB |