Fleet — On-Prem Deployment & REST API
Package Probe Fleet is the enterprise, self-hosted dashboard for org-wide security posture: it ingests scan reports from every machine, tracks seat activity, enforces org-scoped RBAC, and distributes central managed policy. It is a Go service backed by PostgreSQL, deployed on your own infrastructure (Docker Compose or Kubernetes/Helm) — your fleet data never leaves your environment.
Fleet is scoped by organization: each organization maps to one license, and scan reports, ingestion credentials, and audit events are tied to that tenant.
Prerequisites
Section titled “Prerequisites”- PostgreSQL — customer-managed (RDS, Cloud SQL, Aiven) or in-cluster. Fleet stores scan reports, machine heartbeat state, users, credentials, and audit events here.
- A Package Probe license — the commercial source of truth; each Fleet
organization maps to one
license_id. - A reachable license-server (
LICENSE_SERVER_URL) — Fleet requires it at startup and calls it to validate seats and machine ownership on every enroll/report/heartbeat. Air-gap mode only skips the seat-reclamation worker — it does not remove this requirement (see below), so a disconnected deployment still needs a license-server reachable from Fleet. - (Optional) Clerk — for SSO dashboard login. Fleet delegates SAML/OIDC login to Clerk (validating Clerk session JWTs) and consumes Clerk webhooks for user-lifecycle provisioning; it does not run a first-party SCIM server. Without Clerk, Fleet uses API-key auth.
Deploy
Section titled “Deploy”Docker Compose (single host)
Section titled “Docker Compose (single host)”The repository’s fleet/docker-compose.yml runs Fleet + PostgreSQL. Fleet
requires LICENSE_SERVER_URL, LICENSE_SERVER_API_KEY, and FLEET_API_KEY —
it refuses to start if any is missing. Set them and bring it up:
cd fleetexport FLEET_API_KEY="$(openssl rand -hex 32)" # dashboard/admin API keyexport LICENSE_SERVER_URL="https://license.example.com" # seat + ownership checksexport LICENSE_SERVER_API_KEY="ls_..." # license-server authdocker compose up -dFleet listens on port 8081 (http://localhost:8081), with a health check at
GET /health. (The compose file supplies a DATABASE_URL for its bundled
PostgreSQL and a default LICENSE_SERVER_URL, but you must still provide
LICENSE_SERVER_API_KEY and FLEET_API_KEY.)
Kubernetes (Helm)
Section titled “Kubernetes (Helm)”The chart at fleet/helm (packageprobe-fleet) is hardened for production —
non-root, read-only root filesystem, all capabilities dropped, deny-by-default
NetworkPolicy, resource requests + limits, and a PodDisruptionBudget when
replicaCount > 1.
The chart natively models apiKey (→ FLEET_API_KEY), the database, retention,
inactivity, air-gap, Clerk, and the license-server variables
(licenseServer.url / licenseServer.apiKey / licenseServer.existingSecret.name
→ LICENSE_SERVER_URL / LICENSE_SERVER_API_KEY, PKG-780) — Fleet fatal-requires
both at startup unconditionally (including in air-gap mode; see above), so the
chart fails helm template/install/upgrade with a named error when neither
is configured, instead of a silent render that crash-loops the pod after
rollout. For the strongest secret posture, keep credentials in
externally-managed Kubernetes Secrets the chart only references
(database.existingSecret for the DB URL, licenseServer.existingSecret.name
or the generic extraEnvFrom for the license-server variables, ideally
driven by sealed-secrets / External Secrets / vault-injector) so the raw values
never pass through Helm at all. If you’re already supplying
LICENSE_SERVER_URL/LICENSE_SERVER_API_KEY via extraEnvFrom/extraEnv from
before this chart modeled them natively, that keeps working unmodified — the
chart can’t see inside an extraEnvFrom Secret’s keys, so it trusts a
non-empty extraEnvFrom (or a same-named extraEnv entry) and skips its own
guard rather than aborting an already-correctly-configured deployment. Note
that any value you pass inline — via --set or a values file — is rendered
into the Helm release Secret (and --set also leaks to shell history), so
a values file is not itself a secret boundary. apiKey must be non-empty —
the chart renders FLEET_API_KEY from it and fails template rendering if it
is empty — so it necessarily passes through the chart’s own Secret; drive it
from your secret tooling (e.g. an External Secrets-populated value) rather
than committing it:
apiKey: "REPLACE_ME" # required — the chart renders FLEET_API_KEY from thisdatabase: host: fleet-prod.cluster-xxxx.us-east-1.rds.amazonaws.com name: fleet sslmode: verify-full existingSecret: name: fleet-db # the Secret must contain a key literally named key: DATABASE_URL # DATABASE_URL holding the full libpq URLlicenseServer: url: "https://license.example.com" existingSecret: name: fleet-license-server # the Secret must contain a key literally named # LICENSE_SERVER_API_KEY# — or, equivalently, keep using the pre-PKG-780 escape hatch instead of the# dedicated licenseServer.* fields above (either form works, not both):# extraEnvFrom:# - secretRef:# name: fleet-license-server # Secret with LICENSE_SERVER_URL + LICENSE_SERVER_API_KEYhelm install fleet ./fleet/helm -f values.prod.yamlThe chart mounts existingSecret via envFrom, so the DATABASE_URL entry inside it
must be keyed literally DATABASE_URL — a differently-named key is not remapped
and Fleet exits with DATABASE_URL is required.
Fleet won’t start unless LICENSE_SERVER_URL, LICENSE_SERVER_API_KEY, and the
Fleet API key are all present, plus a database. Database configuration has three
forms:
databaseURL— a full libpq URL, e.g. injected by External Secrets Operator. Wins when set.database.*— structuredhost/port/name/user/password/sslmode/extraParamsthat the chart assembles into aDATABASE_URL. Usesslmode: verify-full(the default) so TLS is verified, not just encrypted.database.existingSecret.name— the chart skips generatingDATABASE_URLentirely and mounts your Secret viaenvFrom. That Secret must contain the full libpq URL under a key named literallyDATABASE_URL(the mount is a bareenvFrom, so the key is not remapped — a differently-named key yieldsDATABASE_URL is requiredat startup) — not just a password, and thedatabase.host/name/user/sslmodefields are ignored in this mode. This is the recommended production path (the URL lives outside the chart, managed by sealed-secrets / External Secrets / vault-injector).
replicaCount can be > 1: Fleet runs safely horizontally — sessions and
background workers are stored/coordinated in PostgreSQL, so there is no per-process
state to diverge across replicas.
Configuration
Section titled “Configuration”Fleet is configured entirely via environment variables (the Helm chart maps its values to these).
| Variable | Default | Purpose |
|---|---|---|
PORT | 8081 | HTTP listen port. |
DATABASE_URL | — | PostgreSQL libpq URL (required). |
FLEET_API_KEY | — | Bearer key for the dashboard/admin API (required). |
LICENSE_SERVER_URL | — | License-server base URL (seat + ownership checks). |
LICENSE_SERVER_API_KEY | — | Auth for the license-server client. |
INACTIVITY_DAYS | 60 | Auto-deactivate seats idle longer than this. |
RETENTION_DAYS | 90 | Scan-report retention window. |
MAX_REPORTS_PER_MACHINE | 1000 | Per-machine report cap. |
FLEET_STALE_REPORT_THRESHOLD_HOURS | 48 | Flag a machine’s posture stale past this many hours. 0 disables staleness flagging. |
AIR_GAP_MODE | false | Skip the daily seat-reclamation worker (see below). Does NOT remove the license-server startup requirement or the per-request ownership checks. |
FLEET_LEGACY_INGEST_ENABLED | false | Allow the legacy shared-key ingestion path (migration only). |
FLEET_LEGACY_INGEST_DEADLINE | — | Refuse legacy ingestion at/after this instant. |
CLERK_ENABLED / CLERK_SECRET_KEY / CLERK_PUBLISHABLE_KEY / CLERK_WEBHOOK_SECRET | — | Clerk SSO login + webhook-driven user-lifecycle provisioning (no first-party SCIM server). |
SSO_DOMAIN_MAP | — | Map an email domain to a license, e.g. acme.com=lic-abc,corp.io=lic-xyz. |
FLEET_GROUP_ROLE_MAP | — | Map an SSO group to a Fleet role. |
Air-gap mode (AIR_GAP_MODE=true) disables the daily seat-reclamation
worker (which would otherwise call the license-server to auto-deactivate idle
seats). It does not remove the startup requirement for LICENSE_SERVER_URL /
LICENSE_SERVER_API_KEY, and ingestion still performs machine-ownership checks
against the license service. For a truly disconnected deployment, ensure the
license-server is still reachable from Fleet (or run one alongside it).
Roles (RBAC)
Section titled “Roles (RBAC)”Dashboard access is gated by three roles (ascending privilege):
| Role | Can deactivate machines | Can manage config | Can manage users |
|---|---|---|---|
viewer | no | no | no |
operator | yes | no | no |
admin | yes | yes | yes |
A user’s role is set via the org-users API (default viewer), or mapped from an
SSO group via FLEET_GROUP_ROLE_MAP. A request below the required role gets 403.
Wiring clients to report to Fleet
Section titled “Wiring clients to report to Fleet”A Package Probe client (CLI or app) reports to Fleet through the alerts: block in
~/.packageprobe/config.yaml. The Fleet credential is a secret reference
(env: / file:), never inline (an inline plaintext value is rejected at config
load):
alerts: fleet_endpoint: "https://fleet.example.com/api/v1/report" fleet_credential: "file:/run/secrets/fleet_credential" # per-org credential (preferred)The built-in reporter attaches the replay headers (X-Packageprobe-Request-ID,
X-Packageprobe-Timestamp) and the machine identity Fleet requires. The preferred
path uses a per-organization fleet_credential obtained from enrollment (below); a
legacy shared fleet_api_key exists but the server’s shared-key path is disabled by
default.
Fleet can also push a central managed policy back to opted-in clients
(alerts.fleet_managed_policy: true), applied as the outermost admin layer. See
Policy & registry configuration.
REST API reference
Section titled “REST API reference”Endpoints that return data respond with JSON; write/liveness endpoints return a
status code with no body (201 for a report, 204 for a heartbeat / machine
deactivation / user deletion), and GET /health returns the plain text ok. Check
the status code, and only parse a body where one is documented below. Two auth
models:
- Admin/dashboard API —
Authorization: Bearer <FLEET_API_KEY>(constant-time compared). - Ingestion API (
/report,/heartbeat,/policy) — a per-organization credential (Authorization: Bearer <credential>), plus the replay headers.
Health
Section titled “Health”GET /health → 200 "ok" (no auth)Ingestion (per-org credential + replay headers)
Section titled “Ingestion (per-org credential + replay headers)”POST /api/v1/report and POST /api/v1/heartbeat require:
Authorization: Bearer <org-credential>X-Packageprobe-Request-ID: <unique-per-credential>— usable once (replay guard).X-Packageprobe-Timestamp: <RFC3339>— within 10 minutes of server time.
Fleet also verifies the report/heartbeat license_id matches the credential’s
license, and that the machine belongs to that license (via the license-server).
Request bodies are bounded (8 MiB for a report, 64 KiB for a heartbeat).
| Method + path | Purpose |
|---|---|
POST /api/v1/report | Ingest a machine’s scan report (posture rollup). 201 on success. |
POST /api/v1/heartbeat | Record a machine liveness ping (seat freshness). 204 on success. |
POST /api/v1/enroll | Exchange an enrollment token for a per-org credential (no bearer; the token authenticates). |
GET /api/v1/policy | Fetch this credential’s own org managed policy (org derived from the credential — never a path param). |
The scan report body (POST /api/v1/report) — license_id and machine_id
are required. On the per-org credential path machine_id is the machine’s
hardware fingerprint (it’s what Fleet validates for machine ownership):
{ "license_id": "lic_123", "machine_id": "<hardware-fingerprint>", "fingerprint": "<hardware-fingerprint>", "hostname": "dev-macbook", "project_count": 12, "dep_count": 843, "vuln_count": 5, "kev_count": 1, "critical_count": 0, "high_count": 2, "medium_count": 3, "low_count": 0, "vex_suppressed_count": 1, "policy_pass": false, "policy_violations": 2}Dashboard / admin API (Authorization: Bearer <FLEET_API_KEY>)
Section titled “Dashboard / admin API (Authorization: Bearer <FLEET_API_KEY>)”| Method + path | Purpose |
|---|---|
POST /api/v1/organizations | Create/upsert an organization for a license. |
GET /api/v1/organizations/{orgID}/users | List org users. |
POST /api/v1/organizations/{orgID}/users | Add/update a user + role. |
DELETE /api/v1/organizations/{orgID}/users/{email} | Remove a user. |
GET /api/v1/organizations/{orgID}/policy | Read the org’s managed policy YAML. |
PUT /api/v1/organizations/{orgID}/policy | Set the org’s managed policy YAML. |
POST /api/v1/organizations/{orgID}/enrollment-tokens | Mint an enrollment token (for MDM rollout). |
POST /api/v1/organizations/{orgID}/credentials | Mint a per-org ingestion credential. |
GET /api/v1/licenses/{licenseID} | License details + seat usage. |
GET /api/v1/licenses/{licenseID}/machines | Machines on a license. |
DELETE /api/v1/licenses/{licenseID}/machines/{machineID} | Deactivate a machine (free a seat). |
GET /api/v1/licenses/{licenseID}/summary | Aggregated posture across the fleet — see Reading the compliance rate. |
GET /api/v1/licenses/{licenseID}/machines/summary | Latest scan per machine. |
GET /api/v1/licenses/{licenseID}/vulns | Org-wide deduped CVE rollup (JSON, or CSV with ?format=csv) — see Exporting the CVE rollup. |
Reading the compliance rate
Section titled “Reading the compliance rate”The summary response carries two compliance rates, and they mean different things. Pick deliberately.
| Field | Denominator | Use it when |
|---|---|---|
policy_pass_rate | All reported machines — a machine with no policy configured counts as not passing | You want a single conservative number and will not render a qualifier |
policy_pass_rate_evaluated | Only machines that carry a policy verdict | You will also render machines_policy_evaluated / machines_no_policy |
machines_policy_evaluated | — | The denominator policy_pass_rate_evaluated was computed over |
machines_no_policy | — | Reported machines excluded from that rate |
Two rules keep a rendering honest:
- Never show
policy_pass_rate_evaluatedwithout its denominator. A fleet where nothing was evaluated and a fleet where everything failed both report0. Onlymachines_policy_evaluatedseparates them, and presenting the bare rate turns “nothing was checked” into “everything failed” — an unknown shown as a verdict. - Do not treat
policy_pass_rate_evaluatedas fleet-wide. A fleet of seven machines with two evaluated and both passing reports1.0. That is 100% of what was measured, not 100% of the fleet. Rendering it unqualified is the inverse error: a partial measurement presented as complete.
policy_pass_rate deliberately keeps its original, stricter meaning so existing
v1 integrations do not silently start reporting partially-measured fleets as
fully compliant. Both new fields are additive and stay on v1.
Machines that have never reported are not counted in machines_no_policy —
they have their own machines_never_reported counter, so a qualifier that sums
the two will not double-count.
Exporting the CVE rollup
Section titled “Exporting the CVE rollup”GET /api/v1/licenses/{licenseID}/vulns returns the org-wide, deduped CVE
rollup — the same rows the Vulnerabilities page shows — with each CVE’s blast
radius (which machines, which projects, which dependency versions and their
fixes). Add ?format=csv for a spreadsheet. ?sort=machines orders by blast
radius; anything else uses the exploitability order — KEV first, then severity,
then EPSS within each severity band, then blast radius.
Signed-in dashboard users can download the same CSV from the Download CSV button on the Vulnerabilities page — it scopes to their own session’s license, so no API key is needed.
Every export response is marked Cache-Control: private, no-store. Each tenant
reads it from the same URL, so a shared cache in front of Fleet must never keep a
copy.
The export is a floor, not an inventory
Section titled “The export is a floor, not an inventory”This is the part that matters if the file is going into a compliance packet. A CVE list is only as complete as the fleet that produced it. The rollup can only count machines that reported, ran enrichment, scanned their whole ecosystem set, and did so recently. A machine that never reported is absent from every row — and absence looks exactly like “not affected”.
So the coverage qualifier travels with the data in four places, and you should render at least one of them anywhere you render the rows:
- the
coverageobject in the JSON response; #-prefixed key/value rows above the CSV table — present even when there are zero CVE rows, which is the only place the truth can live in an empty export;fleet_coverageandfleet_coverage_detail, the two leftmost CSV columns, repeated on every row so the qualifier survives a sort, a filter, or five rows pasted into an email;- the
X-Packageprobe-Coverage-Stateresponse header, for automation that archives the file without parsing it.
Read coverage.state first
Section titled “Read coverage.state first”| Value | What it means |
|---|---|
COMPLETE | Every machine reported, was fully enriched, shipped the per-CVE detail its own count claims, scanned an un-narrowed set, and its report age was checked and found current. The only value under which an empty result may be presented as “no vulnerabilities”. |
INCOMPLETE | At least one machine carries a measured gap. Treat the rows as a lower bound. |
FRESHNESS_UNKNOWN | Nothing measured as wrong, but report age was never checked, so how current the rows are is unknown. |
UNKNOWN | No machine has any posture — nothing is enrolled, or nothing has ever reported. |
Alongside it, all_clear_claimable is the actual verdict — complete coverage
and zero findings. A fully-measured fleet that has vulnerabilities reports
state: "COMPLETE" with all_clear_claimable: false. Use state to ask “was
the measurement whole?” and all_clear_claimable to ask “is it safe to say
nothing was found?”.
One more counter worth knowing: machines_findings_missing counts machines whose
own vulnerability count is higher than the per-CVE detail they sent. Their
findings cannot appear in the rows at all, so the export marks itself incomplete
rather than quietly omitting them.
Counting the gap
Section titled “Counting the gap”machines_with_gaps is the number to show next to total_machines — “4 of 7
machines were not fully measured”. Do not add up the per-reason counters to get
it. They overlap on purpose: one machine can be narrowed and stale and
partially covered, so the sum over-counts. The per-reason counters
(machines_never_reported, machines_reported_without_enrichment,
machines_partially_covered, machines_narrowed, machines_stale) are for
explaining why, on their own lines.
One counter needs its own warning: machines_stale: 0 does not mean the fleet
is current. With the staleness threshold disabled it is zero because nothing
was measured. Check freshness_evaluated before reading it.
And one boundary to know: total_machines counts the machines Fleet has a
record of — those that have reported, plus those that finished enrolling. A
machine activated against your license that has never enrolled with Fleet is not
in this denominator, so it cannot appear in the gap counters either. If your
seat count and total_machines disagree, that difference is machines Fleet has
never heard from.
Every successful export — API or dashboard — writes a vulns.exported row to the
Fleet audit log with the actor, format, row count, and the coverage state the
file carried, so a compliance packet can be traced back to who pulled it and what
it claimed. The row is filed under the tenant’s organization, so it appears in
that org’s audit view and is removed by its data-deletion request.
Two notes on license identifiers. The {licenseID} in the API path must be a
canonical UUID — anything else returns 400, because on a legacy deployment that
value can be a raw license key and a key in a URL ends up in proxy logs. If yours
is not a UUID, use the dashboard download instead: it takes the license from your
session, so nothing goes in the path. And in the file itself, a non-UUID
identifier is replaced by a stable ref: fingerprint — you can still match two
exports of the same tenant, but no credential travels in a document you email to
an auditor.
EPSS: absent is not zero
Section titled “EPSS: absent is not zero”The epss field is omitted from a JSON row, and left as an empty CSV cell,
when no client reported a score. That means the exploitation probability was
never measured — it does not mean the probability is low. Do not substitute
0: a measured zero and an unmeasured one are different facts, and the export
keeps them apart.
The same rule shapes the sort. EPSS ranks findings within a severity band
rather than above it, and inside a band an unmeasured score sorts above a
measured one. So an unscored CRITICAL never falls below a scored LOW, and a
finding nobody has scored is surfaced ahead of one measured as unlikely.
Today no client sends a score, so the column is uniformly blank. That is the honest rendering of a signal that does not exist yet.
CSV notes
Section titled “CSV notes”- The file starts with a UTF-8 BOM so Excel renders non-ASCII hostnames and
project names correctly. It sits on a
#comment row, so it never appears in a data field. - Cells beginning with
=,+,-,@, TAB, or CR are prefixed with'. Hostnames, project names and dependency names come from client machines, and without this guard a spreadsheet would evaluate them as formulas. - Multi-valued cells join with
;. A dependency with no known fix says(no known fix)rather than leaving the cell blank. - An unrecognised
?formatreturns400rather than quietly returning a different format.
Enrollment + CI/CD integration
Section titled “Enrollment + CI/CD integration”The recommended way to give a machine (or CI runner) a scoped ingestion credential is the enrollment flow.
-
Create the organization (admin):
Terminal window curl -X POST https://fleet.example.com/api/v1/organizations \-H "Authorization: Bearer $FLEET_API_KEY" \-H "Content-Type: application/json" \-d '{"name":"Acme Security","license_id":"lic_123","email_domain":"acme.com"}' -
Mint an enrollment token (admin — one token can enroll many machines, e.g. a Jamf/Intune rollout):
Terminal window curl -X POST https://fleet.example.com/api/v1/organizations/org_x/enrollment-tokens \-H "Authorization: Bearer $FLEET_API_KEY" \-H "Content-Type: application/json" \-d '{"label":"jamf rollout","ttl_days":30}' -
Enroll the machine (client — the token authenticates; the response returns the per-org credential + the report/heartbeat URLs):
Terminal window curl -X POST https://fleet.example.com/api/v1/enroll \-H "Content-Type: application/json" \-d '{"enrollment_token":"enr_x","machine_id":"mach_123","fingerprint":"hardware-fingerprint","hostname":"dev-macbook","owner_email":"dev@acme.com"}' -
Report — post scan reports with the enrolled credential + replay headers:
On the per-org credential path, the report’s
machine_idis the machine’s hardware fingerprint — Fleet validatesmachine_idagainst the license-server’s enrolled fingerprints, so a divergent placeholder is rejected with403 machine does not belong to license. To have a zero-vuln report render as a clean all-clear (rather than “not checked”), setenrichment_attemptedandenrichment_fully_coveredtotrue— both default tofalse, and a report without them is recorded as not checked, not clean:Terminal window curl -X POST https://fleet.example.com/api/v1/report \-H "Authorization: Bearer $FLEET_CREDENTIAL" \-H "X-Packageprobe-Request-ID: $(uuidgen)" \-H "X-Packageprobe-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \-H "Content-Type: application/json" \-d '{"license_id":"lic_123","machine_id":"<hardware-fingerprint>","fingerprint":"<hardware-fingerprint>","hostname":"ci-runner","vuln_count":0,"policy_pass":true,"enrichment_attempted":true,"enrichment_fully_covered":true}'
Enrollment (steps 1–3) is an operator/admin flow — the Package Probe client
does not exchange enrollment tokens or call /api/v1/enroll itself. Once you
have a per-org credential (from step 3, or minted directly via the credentials
API), configure it as alerts.fleet_credential and the built-in reporter handles
step 4 — posting each scan report to alerts.fleet_endpoint with the required
replay headers and machine identity. In a CI pipeline, store the credential as a
secret (FLEET_CREDENTIAL) and either let the configured reporter send the report,
or curl the POST /api/v1/report call above yourself.
For an alternative single global path, the legacy shared FLEET_API_KEY ingestion
route remains available for controlled internal migration and air-gapped testing —
enable it with FLEET_LEGACY_INGEST_ENABLED=true (it is off by default).
Seat reclamation & posture honesty
Section titled “Seat reclamation & posture honesty”- Auto-deactivation. A daily worker reclaims seats whose machines have gone
quiet longer than
INACTIVITY_DAYS, keyed off Fleet’s own heartbeat store. A machine that is actively heartbeating is never reclaimed; a heartbeat that lands during a sweep wins; deactivation is idempotent. Disabled in air-gap mode. - Coverage honesty. A machine’s zero-vulnerability count is painted clean only when its latest scan affirmatively ran enrichment and covered every dependency. A machine whose report shows enrichment that never ran or only partially covered its dependencies gets an honest “not checked” / “coverage incomplete” chip (and its count isn’t green), so an empty fleet is never mistaken for a clean one. A scan that fails outright (an incomplete discovery, or fully-degraded enrichment) is not reported at all — that machine simply goes quiet and surfaces as stale, never as a false all-clear.
Machine ownership provenance
Section titled “Machine ownership provenance”A machine’s declared owner is the identity Fleet’s deprovisioning chain hangs off: scan reports resolve their reporting user from it, the user↔machine link is written from it, and offboarding reclaims seats through those links. Fleet therefore records where each ownership claim came from as well as what it says.
| Provenance | Origin | Drives offboarding? |
|---|---|---|
enrollment | Declared at POST /api/v1/enroll, gated by an admin-issued enrollment token | Yes |
admin | Confirmed by a Fleet admin on the machine-detail page | Yes |
legacy_heartbeat | Written by a heartbeat — a self-report from the machine | No |
| (absent) | Origin never established (a row predating this field, or a superseded claim) | No |
Only an administratively-asserted claim establishes a link. A heartbeat is authenticated by an organization-scoped credential, so a claim it makes is a self-report — any machine holding that credential could name any owner for itself, either attaching itself to a colleague (whose offboarding would then reclaim this machine’s live seat) or naming a stranger to escape its real owner’s offboarding. Heartbeats have not been able to declare ownership since the enrollment path became the sole declaring act.
An unverified claim is still displayed, badged unverified owner, because a machine that is silently not draining must not look like one with no owner. An admin resolves it by re-enrolling the machine (which supersedes every prior claim on that hardware and is the only way to change who owns it) or by confirming the address shown, which only applies if it still matches what is stored — a stale page, or a machine with conflicting claims, is refused rather than blessed; a confirmation and the user↔machine link it implies are applied together, so a machine never ends up looking confirmed while nothing can drain it.
Links that were already stored before this field existed are re-checked when a user is offboarded. Only a link with no ownership claim behind it, or one whose machine has a single administratively-asserted owner matching the linked user, is acted on; anything else — a self-reported claim, a stale link naming someone else, or a machine with conflicting claims — is removed without reclaiming its seat and recorded in the audit trail, so an old or invalid link cannot deactivate a machine that may not belong to the departing user.
MachineSummary responses carry the claim’s provenance as owner_source
("enrollment", "admin", "legacy_heartbeat", or "unknown"; "unknown" is
a stated absence, never a verdict that the claim is trustworthy).
Fleet records audit events for organization changes, enrollment-token and credential creation, machine enrollment, heartbeat submission, scan-report ingestion, role/user changes, managed-policy changes, and machine ownership claims (a refused auto-link, an admin confirmation, a confirmation that matched nothing, and an offboarding that declined to reclaim a seat are each recorded). License and machine deactivation are audited in the license-server.
Upgrades
Section titled “Upgrades”Fleet uses expand/contract schema migrations, so a rolling upgrade stays available:
deploy the new version, which applies additive migrations, and (with
replicaCount > 1 + maxUnavailable: 0) the API stays up throughout. To roll
back, redeploy the previous image; because migrations are additive, the prior
version reads the newer schema. For customer-hosted deployments, see the
repository’s enterprise operations and upgrade/rollback runbooks
(docs/enterprise-fleet-management.md,
docs/upgrade-and-rollback.md).