The config engine
How the server-side engine plans and applies config documents for every kind, how it records who manages an object, what status an object reports, which kinds are written outside the database transaction, and how Guardian kinds are authorized.
One server-side engine owns every kind. The CLI, the Terraform provider and the console’s Download as code all call it, so they accept the same documents and give the same results. The routes are on Config engine API; the kinds are on the catalogue; the schemas the engine validates against come from config-schemas and each carries an x-vulnetix annotation (singleton, validate only, dependsOn, secrets, celFields) that the engine, the CLI, the provider and these docs all read.
Plan and apply
- Plan reads the current state and compares it with the documents. Per object it reports create, update, delete or no change, with a field diff, a
planHash, warnings and any blocking conditions. It changes nothing. - Apply takes the same documents and the
planHash. It re-plans inside the transaction, under a per-tenant lock, and writes only if the hash still matches; otherwise it answers 409 and writes nothing. - Order. Kinds are applied in dependency order, from each kind’s
dependsOn, and deleted in the reverse order. A reference between objects is bymetadata.name, so both can be in one apply. - Transaction. Every Postgres-backed kind applies in one transaction: all of it or none of it. Some kinds also write somewhere a transaction cannot cover; see Outside the transaction.
- Validation is the console’s own. A document is refused for the same reasons the console form refuses it, because the engine calls the console’s validators rather than copying them.
Who manages an object
Each object records its manager: terraform, console or gitops:<repo>, and the commit it came from. A manager can delete only objects it manages, and only when asked (--prune). A plan shows a change made outside the manager, such as an edit in the console, as drift. Kinds whose objects have no name column of their own (the Guardian kinds, MalwareIoc, VexSource, Campaign and others) keep metadata.name on the ownership record. An object nobody declared is exported under a name derived from its fields, so applying that document adopts it.
Drift protection
An organisation can make the console and the legacy APIs refuse changes to a kind that has a terraform or gitops:<repo> object, with the driftProtection setting of the Organization kind. Platform IaC writes (a vmt_ token, /v1/config/*) always pass, and so do reads and exports. A guarded write answers 409 managed_by_platform_iac, and the guard answers 503 if it cannot read the setting. It is middleware in front of the console routes, not a check in the writers the engine shares, so the engine’s own apply is never blocked. See Drift protection.
Secrets
A field named in a schema’s x-vulnetix.secrets takes valueFrom. The client resolves it (env or file) on its own machine and sends the value once. The engine never exports it, never shows it in a plan, and compares it only to say whether it differs. An export writes a valueFrom.env placeholder.
Status phases
Every applied object has a result: Applied, Unchanged or Deleted. A kind can add a status, and a phase field says where the object stands:
| Phase | Meaning | Kinds |
|---|---|---|
Pending |
Applied, and waiting on a person. | Member: the invitation is sent and has not been accepted. The status carries invitationExpires. |
AwaitingAuthorization |
Applied, and waiting on a person to authorize it in a browser. The status carries authorizationUrl. |
MessagingConnection for Slack; AwsConnection with sso, which also carries userCode and then reports Authorized or AuthorizationRequired. |
Failed |
The object’s write in the second phase failed. The status carries a message, and nothing is recorded for the object. |
The Guardian kinds and VaultEntry. |
Some values are shown once, in the result of the apply that creates the object, and never by plan, export or a later apply: a ServiceAccount’s apiKey and secret, a LogSource’s ingestToken, and a webhook MessagingConnection’s signing secret. Plan never shows them, because they exist only after the write.
A kind can also report read-only facts after the write, such as a VexSource’s latest fetch, a GitHubInstallation’s account, or a FactorySettings revision. Each kind’s page says what it reports.
Outside the transaction
An apply that writes outside Postgres inside the transaction registers an undo with OnRollback. The undos run newest first when the transaction does not commit. Calls to AWS, KMS and the secrets vault cannot be rolled back, so a rolled-back apply can leave an unused OIDC client or a vault version nothing references.
Two groups of kinds are written in a second phase, after the Postgres transaction commits, because the store cannot join it:
- The Postgres transaction applies every other kind, records no-ops and label-only updates for these kinds, and commits.
- Each create, update and delete of these kinds then runs on its own, in dependency order, in a transaction of its own that holds the tenant’s config lock and writes the ownership row and the audit entry. When the external store took the write but recording it fails, the write is undone: a create is deleted, an update is written back.
- Each object has its own result. One that fails has
status.phase: Failedand amessage, and nothing is recorded for it. The others, and the Postgres phase, stay applied.POST /config/applyanswers 207, withfailedcounting them. Plan again to see what is left. - A single-object
PUTorDELETE, which is what the Terraform provider sends, answers 502external_failedwhen the store refuses, and records nothing.
The two groups are the five SIEM kinds and VaultEntry, whose records live in the tenant’s KMS-encrypted vault file in S3.
SIEM kinds, written in Guardian
SavedSearch, DetectionRule, AlertTrigger, ScheduledReport and LogSource live in Guardian’s own store, behind its console API. The engine writes them through that API as a second phase.
- Credential and tenant. The engine calls Guardian with the caller’s own
vmt_token (Authorization: Bearer vmt_...) and nothing else, and never sends a tenant. Guardian checks the token with a service-to-service introspection call, takes the tenant, role and kinds from the token’s record, and requires the tenant Postgres resolves for the token’s principal to be the same one. A request can only reach the tenant the token belongs to, with the role and kinds it was issued with. A shared service secret with a tenant header was ruled out: whoever held it could name any tenant. - Before planning, the engine asks Guardian who the token is, and blocks the plan when Guardian resolves it to another tenant, when the tenant has no Guardian pack, or when Guardian refuses the token.
- The cost. Only a token caller can plan, apply, prune or export these kinds. For a console session or an ApiKey the five kinds are left out of what the caller may change (
kind_not_allowed), so a session can never prune them. - Names and references. Guardian objects have ids, not names.
searchRefandruleRefare names in documents and Guardian ids on the wire. A reference to an object declared in the same apply works, because objects are written in dependency order. A messaging connection is referenced by the name aMessagingConnectionwas declared under; one nobody declared is addressed by its id, and Guardian checks it belongs to the tenant.
Roles and tokens
Every Org kind blocks a change by a caller who is not an owner or admin. Role rules are shared with the members page: only an owner grants owner, admin is never granted, and a plan whose result has no owner is refused (no_owner). The person applying cannot remove themselves; that check runs at apply time, because plan has no principal.
A token for Organization, Member, ServiceAccount or VaultEntry must have the owner role: these decide who can act for the tenant, so a leaked admin or member token can never add a member, mint a service account or read a secret into a place it controls. Issuing and managing tokens is on Config engine API.
Besides config kinds, a token can hold read scopes:
| Route | A vmt_ token needs |
|---|---|
/campaigns and everything under it |
The campaigns scope, for GET and HEAD only. Any write with a token is 403. |
GET /v1/ai-firewall/models |
AiFirewallPolicy or AiProvider |
GET /v1/integrations/github/installations/repos |
The github-installations scope |
GET /v1/vex/statements |
VexSource or Suppression |
Guardian’s search, alerts, usage and billing routes refuse tokens. Its configuration routes (searches, rules, triggers, sends and sources) accept a token that lists the matching SIEM kind; the member role reads only.
GitOps from a repository
A Software Factory workflow node, config-apply, lets a config repository manage the tenant. Trigger it on a push webhook to the repository’s default branch. See FactoryWorkflow for what it does, and the config-plan and config-apply GitHub Actions for the same flow in CI.
Software Factory settings
FactorySettings, ScannerProfile, FactoryManagedOrg and NotificationDefaults each own a disjoint set of keys in the one settings document, so applying one never writes a key another owns. The console’s Export as YAML on the factory settings page renders these documents with the engine.