Skip to main content

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 typeOperationsConnector lane
vertex.opEvery operation of a write classvertex: one at a time, in order
vertex.readOperations of class readvertex-read: two in parallel, next to a running change

Both carry the same payload, an OpJob:

FieldMeaning
operation_idVertex's operation ID: names the job's secrets and its chunks.
opThe operation (Operations).
managed_ousThe tenant's managed OU DNs (at most 100). Writes outside them are refused; the guard compares them with the accepted list.
serverOptional domain controller host name (default: the local one).
paramsThe operation's parameters (JSON; types in proto/vertex/params.go).
expires_atThe 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​

opClassDeletePasswordsparams
testreadnone → result test (TestResult)
syncreadSyncParams {kinds?, user_attributes?, search_base?} → chunks objects, gpos
schemareadSchemaParams {classes?} → chunks schema
object.getreadGetParams {target, attributes?} → object with writable
gpo.reportreadGPORef {id} → chunks report
gpo.backupreadGPORef {id} → backup_id
user.createuser✓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.updateof the targetUpdateParams {target, set?, add?, remove?, clear?}
user.passworduser✓PasswordParams {target, password_key, must_change, unlock?}
user.unlockuserTargetParams {target}
user.enableuserEnableParams {target, enabled}
object.moveof the targetMoveParams {target, target_ou}
object.renameof the targetRenameParams {target, new_name}
object.deleteof the target✓DeleteParams {target, recursive?} (OUs: recursive)
group.creategroupGroupCreateParams {ou, name, sam_account_name?, scope, category, description?, attrs?}
member.changegroupMemberParams {group, add?, remove?} (DNs, at most 500 each)
ou.createouOUCreateParams {parent_dn, name, description?, protect_from_deletion}
pso.setpsoPSOParams {target?, name?, settings?, apply_to?, unapply?} (no target: create)
gpo.creategpoGPOCreateParams {name, comment?, link_to?}
gpo.updategpoGPOUpdateParams {id, name?, status?, comment?}
gpo.deletegpo✓GPORef {id}
gpo.linkgpoGPOLinkParams {id, target_dn, action: link|unlink|set, enabled?, enforced?, order?}
gpo.registrygpoGPORegistryParams {id, set?, remove?} (RegistryValue {scope, key, value_name, type?, value?}, at most 100)
gpo.permissiongpoGPOPermissionParams {id, trustee, trustee_type, level, replace?}
bulk.usersuser✓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:

  1. Connector → Axis: POST /api/connector/v1/vertex/jobs/{jobID}/secrets {"operation_id": "…"} with the connector key.
  2. 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 same operation_id); otherwise 403 job_not_live.
  3. Axis → Vertex: POST /internal/v1/axis/secrets {tenant_id, connector_id, job_id, operation_id}.
  4. 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 answer Secrets; the operation's sealed passwords are deleted as they are handed out.
  5. 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).

kinditemsSent 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):

DirectionEndpointPurpose
Vertex → Axis (RMM_INTERNAL_ADDR, :8089)GET /internal/v1/tenants/{tenant}/connectorsThe tenant's connectors that are not revoked (AxisConnector: id, name, hostname, domain, version, status, capabilities, last seen).
POST /internal/v1/tenants/{tenant}/jobsQueue 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}/cancelCancel it (and tell the connector).
Axis → Vertex (VERTEX_INTERNAL_ADDR, :8088)POST /internal/v1/axis/secretsBridgeSecretsRequest → Secrets.
POST /internal/v1/axis/chunksBridgeChunk → 204.
POST /internal/v1/axis/jobs/finishedBridgeJobFinished → 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:

  1. validates the OpJob (invalid_payload);
  2. 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);
  3. fetches the secrets (unauthorized when refused);
  4. 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;
  5. 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 the ActiveDirectory and GroupPolicy modules, checking the managed OUs, protection and the denied attributes again before it writes;
  6. 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):

CodeMeaning
guard_pendingThe local guard is not accepted, the job's managed OUs differ from the accepted ones, or the guard file is not trusted.
guard_deniedThe local guard does not allow this class, or deletes.
rate_limitedThe connector's local cap of writes per minute (default 1,000, at most 2,000).
expiredThe job arrived after expires_at.
unauthorizedVertex refused the secrets request (the message says why).
out_of_scopeThe object or target is outside the managed OUs (a PSO: outside the Password Settings Container).
protectedA privileged or built-in object, or a default domain policy.
denied_attributeAn attribute on the deny list.
not_foundThe object does not exist.
already_existsAn object with this name exists.
password_policyThe password does not meet the policy.
access_deniedThe AD account lacks the right (or may not open the PowerShell session: add it to Remote Management Users).
logon_failedThe AD account's credentials were refused.
missing_moduleThe ActiveDirectory or GroupPolicy module is missing.
ad_errorAny other AD or PowerShell error, or results that could not be delivered.
invalid_payload, unsupported, cancelledAs for every job (Job error codes).

What they mean for users: Vertex troubleshooting.

Limits​

LimitValue
Managed OUs100
Rows per bulk.users job200
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.registry100
Values per attribute / value size1,000 / 64 KiB
DN length2,048
Password length256
Items per chunk500
Create-job request body (Axis)4 MiB