API - rogers-group-inc/polaris GitHub Wiki
Polaris exposes a REST API at /api/v1/ for external callers β SIEM
integrations, scripts, CMDB syncs, dashboards.
The canonical, always-current reference is the in-app page at
/api, served by your own install. It is generated from the code and cannot drift. This page is the same material for readers who do not have an install in front of them, plus the parts that are easier to explain in prose.
/apiis unauthenticated but source-IP-gated β default RFC1918 + loopback, configurable at Server Settings β API Tokens. The gate drops the socket and fails closed.
A bearer token, minted at Server Settings β API Tokens and bound to a role at mint time.
curl -H "Authorization: Bearer $POLARIS_TOKEN" \
https://polaris.example.com/api/v1/assets?limit=1Tokens look like polaris_<32-character-tail>.
| Situation | Answer |
|---|---|
| missing, revoked, expired or malformed token | the same 401 as no token at all |
| a valid token sent from outside its trusted hosts |
403, naming the source address Polaris saw |
| a request the token's role does not permit |
403 β except on filter-don't-403 surfaces, which narrow instead |
any unknown path under /api/v1 to an anonymous caller |
401, not 404 β the API does not tell an anonymous caller which endpoints exist |
GET /auth/me answers
{"authenticated": false} for bearer callers. To smoke-test a token, call a
cheap read and check for 200.
Writes are attributed in the audit log as api:<token name>.
CSRF is skipped for bearer requests β a cross-site attacker cannot attach a custom header β while authentication is fully enforced downstream.
Networks, reservations, contacts, credentials and Discoveries carry an
ownership dimension: write reaches
only rows whose creator matches the caller.
A token has no username. So a token bound to a role with plain write on
those keys can create rows but gets 403 on every edit or delete of an
existing one.
Bind integration tokens to a role holding fullwrite on the keys they need
to modify.
Every error is JSON with a single error string:
{ "error": "Reservation not found" }| Code | Means |
|---|---|
400 |
validation failure β the message names the offending field |
401 |
missing or unusable credentials |
403 |
authenticated, but the role lacks the permission β or the token came from outside its trusted hosts |
404 |
no such resource, or one outside the caller's visibility scope |
409 |
conflicts with current state β an overlapping network, a duplicate reservation, a referenced row |
429 |
rate-limited; back off and retry |
5xx |
server-side failure; the body still carries { "error": ... } where possible |
404 rather than 403 for an invisible row is deliberate and applies to
alerts, saved dashboards and Discoveries: a 403 would confirm the id exists.
Aggregate feeds under /dashboard/* are cached server-side for about 10 seconds.
Three renames are dual-mounted:
| Old | New |
|---|---|
/notifications |
/alerts |
/notification-rules |
/automations |
/notification-channels |
/delivery-channels |
Responses on the old paths carry Deprecation: true plus a Link header naming
the successor. Move to the new paths.
GET /assets list + filter
GET /assets?search=<mac|ip|hostname> the SIEM lookup
GET /assets/:id
GET /assets/:id/sources every source's answer
GET /assets/:id/sightings FortiGate sightings
GET /assets/:id/dependencies the dependency tree
GET /assets/ip-check?ip=β¦ is this address already in use?
POST /assets create a device
PUT /assets/:id update + the monitoring surface
POST /assets/bulk-monitor flip monitoring on many at once
POST /assets/bulk-tags add / remove / replace tags on many at once
DELETE /assets/:id
PUT /assets/:id/primary-address pin the (MAC, IP) the device is monitored on
DELETE /assets/:id/primary-address release that pin
POST /assets/:id/address-check score an address before pinning or typing it
GET /credentials stored credentials, secrets masked
azure: tags belong to the Azure Arc integration. POST /assets, PUT /assets/:id and POST /assets/bulk-tags ignore any azure: tag in the body
and always keep the ones a device already has.
PUT /assets/:id/primary-address takes { mac, ip } β one of the device's own
addresses, as listed in associatedIps on GET /assets/:id (each entry names
its mac, the reporting gate device, and medium). Reading GET /assets/:id
first checks the device's DHCP lease and reservation entries against IPAM, and
drops one IPAM has released or holds for a different MAC. A pair not on that list,
or a FortiGate / FortiSwitch / FortiAP managed by a Fortinet integration, is a
409. While pinned, discovery does not change ipAddress, except to follow a
renumber: when the pinned IP has gone unseen for 24 hours and the same MAC has
exactly one recently seen address, the pin moves to it. Typing or clearing the
IP through PUT /assets/:id releases the pin, as does DELETE, which moves the
device to the address on its own list with the highest evidenceScore (network
evidence including polling, no operator weight; a tie keeps the current
address). Only when the list is empty does DELETE fall back to what discovery
reports. See rule 102 and
rule 103.
GET /assets/:id carries addressConfidence, a score for every address on
the device keyed by IP: { score, evidenceScore, discoveryScore, band, evidenceBand, parts, penalties, sustained }. score runs 0β100 and counts an operator's
pin or typed address; evidenceScore is the network evidence alone;
discoveryScore (0β100) is discovery's evidence alone β no operator, no
polling β and is what the automatic pin release compares. band is high (80 and up),
medium (50), low (20) or very-low. Each parts entry is one kind of
evidence ({ kind, label, device, seenAt, hours, value }); a gate's sighting
has the kind detected-ambiguous, and counts for far less, when that gate's ARP
table shows the network card with several addresses. Each penalties entry is
{ kind, factor, detail }, a multiplier on the score: alive-elsewhere,
claimed-elsewhere, or arp-elsewhere β the address rests on what a gate
detected, no ARP entry backs it, and a gate's ARP table has that MAC at a
different address. sustained is true while Polaris's own polling is answering
on the address: its evidence is then not aged in score and evidenceScore,
while discoveryScore still ages it. The list returns the compact form,
{ score, band } per address. Nothing alerts on the score; it only picks the
address an unpinned device moves to and when a pin is released.
POST /assets/:id/address-check (assets:write) takes { ip, probe? } and
returns the same fields for that address plus warn (true when
evidenceScore is below warnBelow, 50) and probe β { answered, error }
from one ping sent by the server when probe is true, else null. An address
the device has never been seen on scores 0. Nothing is written, and the pin and
PUT /assets/:id never refuse a low score. An invalid IP is a 400, an
unknown device a 404. See rule 103.
Creating a device and putting it under monitoring is two calls. POST /assets carries inventory fields only β monitored and every *CredentialId
are rejected there and belong to the PUT.
Credentials are not created over the API in the normal flow: an operator
saves them once under Server Settings β Credentials, and the integration
references the stored row by id (resolve it by name through GET /credentials,
or paste the id into the client's config). This also sidesteps the ownership
trap β a token that never writes a credential never needs
credentials:fullwrite. assets:write alone covers both calls; add
credentials:read only if the client resolves ids by name.
Duplicate addresses are checked as you write. Both calls return an
ipConflict field β null, or { conflictId, ip, qualifiedBy, members[] } when
the address you just set is already recorded by another network-present asset.
Polaris raises the Duplicate IP conflict on the spot rather than on its
ten-minute sweep, and a PUT that moves a device off a contested address
closes that conflict in the same call. Ask before writing with
GET /assets/ip-check?ip=&excludeAssetId=&assetType=&macAddress=
(assets:read): it returns every current holder (holders[], each with
claimCurrent β a stale record is listed but does not collide),
wouldConflict, and canMerge (whether the caller holds assets:fullwrite,
which merging requires). The answer uses the same rules the write applies, so a
wouldConflict: true is a conflict the save will raise. See
Conflict Resolution.
A device created this way is a manual-source asset, and only response time
gets a source default (ICMP). Every other stream stays dark until a polling
method is chosen for it, so monitored: true on its own buys you ping and
nothing else.
Two refusals to code against, both 400:
-
Credential <id> not foundβ the id is well-formed but names no stored row. Usually a credential deleted and recreated in the UI, which issues a new id. -
A credential type the stream's transport cannot use β the pairing is
snmpβsnmp,winrmβwinrm,sshβssh,rest_apiβrestapi. ICMP, Disabled, Agent, vCenter and FortiManager take no per-asset credential, and a credential left beside one of those is accepted as staged config.monitorCredentialIdis never type-checked β it is the fallback for every stream at once, and those streams may legitimately poll over different transports.
monitored: true on a decommissioned, disposed, lost or disabled device is a
409 (rule 10) β change the status first.
POST /assets/bulk-monitor is the exception to that shape: per-id problems come
back in errors[] with HTTP 200 while the rest of the batch applies, because a
mixed selection is the normal case. An unknown monitorCredentialId still fails
the whole call, since it applies to every id in the batch.
GET /assets/quarantine-availability is push enabled anywhere?
POST /assets/:id/quarantine
POST /assets/:id/quarantine/verify
GET /assets/:id/quarantine-status
DELETE /assets/:id/quarantine
POST /assets/bulk-quarantine
GET /assets/sighting-settings PUT to change
Check availability first. Quarantine push skips push-disabled integrations, so on an install with none enabled the push returns "0/0 FortiGate(s) accepted the push". Every UI surface that offers quarantine reads that endpoint and withholds the verb when it is false. Release is never gated on it.
A token granting quarantine at write or above must name its scoped
integrations.
Note that quarantining a device also makes it unmonitorable (rule 10) β every probe would fail by design, since the device is isolated at the gate. The monitored flag is parked and restored on release.
GET /dashboard/noc-summary
GET /dashboard/summary
GET /dashboard/filter-options
These are filter-don't-403 surfaces: a token whose role cannot read something gets a narrower payload, not an error.
/dashboard/filter-options returns the vocabularies the widget filters accept:
assetTypes, regions, fortigates and tags (the fleet's asset tags, with
region: tags left out β they are regions).
/dashboard/noc-summary takes ?feeds= to select the subset a caller renders.
Each feed is gated on the key that owns its data β assets:read for most of
them, events:read for recentReboots, alerts:read for activeAlerts, and
maintenanceManagement:read for maintenanceSchedules, the feed behind the
Active Maintenance widget. A kiosk token therefore reads the maintenance feed
only if its role was granted that key.
The maintenance rows state their window times in the Polaris server's local
wall clock (startedAt / endsAt, no offset β the recurrence engine is
evaluated against that clock) and repeat the end as a true instant
(endsAtUtc) for a countdown. Unlike every other feed, the asset filter does
not narrow them: a schedule is returned when any of its devices is in scope,
whole, with matchedCount giving the in-scope share.
GET /path-checks
GET /path-checks/:id
GET /path-checks/:id/results
POST /path-checks
PUT /path-checks/:id
POST /path-checks/:id/enabled
DELETE /path-checks/:id
POST /path-checks/preview-sources
POST /path-checks/test
GET /path-checks/filter-schema
GET /path-checks/:id/server
GET /path-checks/:id/server/history
GET /path-checks/:id/server/traceroutes
GET /assets/:id/path-checks
GET /assets/:id/path-check-history?checkId=
GET /assets/:id/path-check-traceroutes?checkId=
A path check is an HTTP / HTTPS request, a TCP connect or a
ping that the Polaris Agent (0.21.0 or later) runs from each of its hosts, with
an optional traceroute. The /path-checks endpoints gate on the pathChecks
key; the three /assets/:id/β¦ readings gate on assets:read, because they
describe that asset.
-
Hosts are the check's
scope(the automation device-filter tree,{ "allAssets": true }for every agent host) plus pinnedassetIds, limited to hosts running an active agent.preview-sourcesdry-runs that selection without saving; itsidslists every selected host (up to 2000), past the 100 rows it describes.sourceFilter({ condition }ornull) is the filter the UI used to find hosts before ticking them intoassetIds; it is stored for display only and never decides who runs the check. -
The Polaris server runs the check too when
runOnServeristrue. One ofscope,assetIdsandrunOnServermust select something. Setting it, changing what a server-run check sends, or re-enabling one also needsnetworkScan:write(403otherwise). The server is not an asset: it is the first/resultsrow (server: true,assetId: null), its readings are the three/path-checks/:id/serverβ¦endpoints (onpathChecks:read), and its results raise an automation alert only when the trigger carries"includeServer": true(below). -
Targets are a full URL for HTTP / HTTPS,
host:portfor TCP and a bare host for ICMP, IPv4 only. Loopback, link-local, cloud-metadata and multicast addresses, and the Polaris server itself, are refused with400. -
Limits:
intervalSecis whole minutes (60β3600);timeoutMsis 500β30000 and at most half the interval; a body-match regex must be RE2-compatible. At most 50 checks can be enabled (409past that), and a host runs at most 20 β the oldest win, and the rest raise apath_check.agent_over_capevent. -
PUTreplaces the whole definition; it is not a patch. -
httpalso takesmethod(GET|HEAD, nothing else),hostHeader,followRedirectsandbodyMatch.negate; a check using any of them needs agent 0.23.0.credentialId(an HTTP credential, Bearer / Basic / Digest) makes the check server-only: agentscope/assetIdsare refused with400, and using the credential needs at leastcredentials:read(403otherwise). -
testruns a draft once from the Polaris server and returns the verdict, timings and, for HTTP / HTTPS, the headers (cookie values redacted) and the first 64 KB of the body. Nothing is saved but an audit event. SamenetworkScan:writerequirement as running from the server; 10 a minute per caller (429past that). - A check has no threshold and never changes a host's Up / Down status. To be
alerted, build an automation on the
path*metrics or thepath_check_path_changedchange type β the wizard's Path Monitor trigger. Its devices are always the rule'sscopeANDed with Polaris Agent installed, and a composite may not mixpath*conditions with any other."includeServer": trueon a singlepath*condition (or on the change trigger) adds the Polaris server as a source; it is refused beside areset.modeofcondition, and its alerts carryassetId: nullandassetHostname: "Polaris server".
path-check-history takes the same range= presets and from / to as the
other history endpoints and returns per-run samples on the detail tier, or
per-bucket averages with okCount / failCount on the hourly and daily tiers.
path-check-traceroutes returns the newest traces first (limit β€ 50). Each
hop carries the asset, interface and subnet Polaris matched its address to at
the time of the trace.
GET /search?q=
Seven groups in one response β blocks, subnets, reservations, assets, ips,
sites, ipsec β each gated on its own read key and empty when the caller may not
read it. ipsec hits are a firewall's IPsec tunnels and the peers or VPN users
connected through them (id is <gateAssetId>|tunnel|<name> or
<gateAssetId>|conn|<name>; context.assetId is the gate, context.peerAssetId
the far-end device when known). Supports the same
prefixes the UI does
(block:, network:, asset:, reservation:, map:, tag:, ipsec: / vpn:).
GET /blocks GET /blocks/:id
POST /blocks PUT /blocks/:id DELETE /blocks/:id
Each GET /blocks row carries utilizationPercent (0β100): the share of the
block's address space carved into non-deprecated networks.
GET /subnets GET /subnets/:id
GET /subnets/:id/ips
POST /subnets blockId optional β omitted = most specific containing block
GET /subnets/resolve-block?cidr= which block that would be
POST /subnets/next-available allocate the next free /N
POST /subnets/bulk-allocate anchor-aligned, all-or-nothing
PUT /subnets/:id DELETE /subnets/:id
POST /subnets/:id/refresh the Discover button: DHCP + firewall VIPs
GET /subnets/:id/move-targets blocks that can hold it, overlaps flagged
POST /subnets/:id/move { blockId } β re-parent onto another block
POST /subnets/:id/archive fullwrite
GET /subnets/archived GET /subnets/archived/:id
GET /subnets/exclusions
POST /subnets/exclusions fullwrite
PUT /subnets/exclusions/:id fullwrite β name and notes only
DELETE /subnets/exclusions/:id fullwrite
Each row from GET /subnets carries its address usage:
_count.reservations (reservations holding an address right now β active, with
an ipAddress; released and expired rows and whole-network reservations are
not counted), usableHosts (what the CIDR can hand out, null for IPv6),
utilizationPercent (the first over the second, to one decimal, null
wherever usableHosts is) and totalReservations (every reservation row
whatever its status β what a delete removes).
An exclusion's CIDR is its identity and is frozen after create β the PUT accepts name and notes only (rule 42). Changing the range is a delete plus an add.
GET /reservations
POST /reservations
POST /reservations/next-available
PUT /reservations/:id DELETE /reservations/:id
GET /reservations/push-queue
Behaviours to code against:
-
Creating over a
dhcp_leaseis a create, not a release. The lease is observed presence; Polaris supersedes it server-side. Creating over amanual,dhcp_reservation,viporinterface_iprow returns 409. - On a push-enabled network the push is verified by read-back, and any failure aborts the create entirely. You never end up with a Polaris row the gate has never heard of β so a 5xx here means nothing was written, not partially written.
-
On a push-enabled network
notesis length-budgeted, and over-length is a 400. The notes become the FortiGate entry's description, which the device holds 255 characters of β including thePolaris/<user>: β¦ [<hostname>]wrapper Polaris writes around them, so the usable length depends on the creator's username and the hostname. The error names the budget and the overage. Nothing is truncated, and nothing is judged on a network with no DHCP Push (rule 74). -
The all-zero MAC is a 400.
00:00:00:00:00:00(any separators) names no device, so a create with it, or an update that changes the MAC to it, is refused. OmitmacAddressinstead. An update that sends back a zero the row already holds is accepted. On assets the same value is stored as no MAC rather than refused (rule 97).
GET /events
The audit tail. Events older than 7 days are pruned by default.
Three, declared on the app itself, outside the permission model and gated only
by optional tokens from .env:
| Endpoint | |
|---|---|
GET /health |
liveness. 200 whenever the process is up, and checks nothing at all β the setup wizard polls it before a database exists. Gated by HEALTH_TOKEN when set |
GET /health/ready |
readiness. 200 only when the local PostgreSQL is a writable primary; else 503 with a reason (in-recovery / db-error / timeout). Never cached. Gated by HEALTH_TOKEN too. This is the one a load balancer should monitor
|
GET /metrics |
Prometheus. Gated by METRICS_TOKEN when set, and by an nginx allow block (the Web Server tab's Prometheus allow-list) in production |
The setup wizard generates both
tokens, so on a wizard-built install all three answer 401 until the caller
sends Authorization: Bearer <token>. Clearing a token opens its endpoint, and
the Maintenance tab warns while it is unset.
None carries a session or a role snapshot, and all three are served only by the
web and all process roles.
Every response β including a 404 β carries the app's security headers
(rule 50). An unmatched route is answered by the
app, never dropped to the framework's default handler, which would replace the
Content Security Policy with one that drops frame-ancestors and form-action
and echo the request path back in an HTML body.
The 404 message is a constant. Nothing you send is reflected.
-
Paginate. List endpoints take
limitandoffset, and return an unpaged total. Read the total; do not infer truncation from a full-looking array. -
Expect
dhcpBindingandsourceTypeto disagree β they answer different questions (rule 23). -
Do not assume
Asset.ipAddressis unique. Two rows on one address is a state Polaris must be able to represent in order to report it (rule 40). -
Treat
passiveas a real monitor status, not as an error. Anything testing!== "up"as a proxy for unreachable must exempt it (rule 36). - Back off on 429.
A generated client guide to this API lives in a separate repository,
polaris-api-conventions, produced from the in-app /api page. It is
regenerated when that page changes.