Skip to content

Enrollment & config parity: per-OS flags, cert upload, assembled config + SPA tabs/sliders/forms/docs links#849

Merged
javuto merged 57 commits into
jmpsec:mainfrom
alvarofraguas:pr/enroll-config-parity
Jun 10, 2026
Merged

Enrollment & config parity: per-OS flags, cert upload, assembled config + SPA tabs/sliders/forms/docs links#849
javuto merged 57 commits into
jmpsec:mainfrom
alvarofraguas:pr/enroll-config-parity

Conversation

@alvarofraguas

@alvarofraguas alvarofraguas commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR closes the parity gap between the legacy admin's environment-management surface (/conf/{env_uuid}, /enroll/{env}) and the new React SPA at frontend/. The work covered 10 items — 3 API extensions + 7 SPA additions — and along the way picked up a handful of UX-consistency and security improvements surfaced by an in-house audit pass.

What's in here

Backend (3 new endpoints):

  1. GET /api/v1/environments/{env}/enroll/flags{Linux,Mac,Windows,FreeBSD} — per-OS flags with
    __SECRET_FILE__ / __CERT_FILE__ placeholders substituted for the platform's canonical
    install path. Same substitution legacy admin's download path performs via generateFlags.
  2. POST /api/v1/environments/{env}/enroll/cert — base64-PEM upload with strict PEM + x509
    validation server-side. Capped at 64 KiB. Audit-logged.
  3. GET /api/v1/environments/{env}/configuration/assembled — returns the cached assembled
    osquery configuration JSON (composed by RefreshConfiguration, which already fires from
    every parts mutation). Pure read, no side effect.

SPA (env configuration page reorg):

  • Tabbed editor on /_app/env/{env}/config: Settings (intervals sliders + expiration)
    followed by one tab per JSON section (Options, Schedule, Packs, Decorators, ATC, Flags).
  • IntervalsCard uses three range sliders with the exact bounds from legacy admin's conf.html
    (Configuration / Logging 10–600 step 10, Query 10–300 step 1) plus a numeric readout.
  • AddOptionForm + AddScheduledQueryForm — inline forms above the Monaco editor on
    Options + Schedule respectively. Parse the current draft JSON, append, re-serialize, push
    back to the editor.
  • DocsLink atom (new) — small `? docs` chip in every section header, opens the matching
    osquery read-the-docs anchor in a new tab. Surfaces in EnvConfig and Enroll tabs.

SPA (enrollment page reorg):

  • Page split into three tabs: Install (default — scripts + collapsible Package URLs),
    Configuration (FlagsCard with per-OS download + AssembledConfigCard Monaco viewer),
    Lifecycle (Enroll/Remove secret cards + full CertificateCard with paste-or-upload replace).
  • CertificateCard surfaces the env cert preview (first PEM line + last 60 chars) with
    copy/download/replace.
  • FlagsCard with per-OS tabs consumes the four new flag endpoints.

SPA (multi-select consistency across list pages):

  • Tags, Operators (Users), and Environments pages gained the same multi-select dock as
    Carves/Queries/Nodes. Per-row Edit/Delete preserved; bulk delete via Promise.allSettled
    with partial-success reporting.
  • Operators page's bulk-select correctly excludes the current operator's own row.

SPA (other polish):

  • NodesTablePage filter chip pad restyled to match Queries' StatusTabs format; old duplicate
    inline status pad removed.
  • Profile page reorganized from a single tall Security mega-card to a 2×2 grid of focused
    cards (Account · Password · API token · Preferences).
  • Settings page restyled with the same EnvConfig section chrome.
  • Node detail page — Archive and Delete collapsed into a single Archive button. Archive
    always snapshots into `archive_osquery_nodes` before removing the live row, so every
    removal is forensically recoverable. The previous two-button shape mapped to the same
    backend op and read as misleading.
  • Query detail page — Data cell now renders one nested mini-table per result item (matching
    the legacy admin's render shape) instead of extruding all keys to the outer table.
  • Carve detail page — node UUID column linked to node detail.
  • SideNav gained a Configuration entry under Enrollment.

Security pass (in-house audit before push):

  • H1: `EnvConfigurationHandler` dropped its inline `RefreshConfiguration` call —
    CSRF-via-GET hazard + DB-write hot-loop closed. Read serves the cached value; refresh
    still fires from every parts mutation.
  • H2: All 7 permission-deny sites in `environments_crud.go` now route through
    `denyEnv`, restoring audit-log visibility on cross-tenant probes against the new CRUD
    endpoints (the older `environments.go` handlers already did this).
  • M1: `EnvCertUploadHandler` wraps the body with `MaxBytesReader` at 64 KiB.
  • M2: AddOptionForm + AddScheduledQueryForm refuse `proto` / `constructor` /
    `prototype` as option/query names.
  • M3: `substitutePlatformPaths` uses `strings.ReplaceAll` for forward compatibility.

Known scope

  • Environment delete is one-arg-keyed (`DELETE /api/v1/environments/{env}`) — the
    older `EnvActionsHandler` belt-and-braces pattern that requires both name + UUID in the
    body was deliberately not ported, to keep the new CRUD endpoints REST-idiomatic. Follow-up
    PR could add an optional `?confirm=` query param for the same protection.
  • Environment delete does not cascade — nodes, queries, carves, tags scoped to the
    deleted env are orphaned. Documented in the SPA's confirm copy. Same shape as legacy
    admin. Follow-up could add cascade or a referential-integrity check.
  • No restore UI for archived nodes — `archive_osquery_nodes` holds the forensic
    snapshot but there's no "Restore" button yet. Operators can recover via SQL or by
    letting the agent re-enroll. A future `/archive` page is the natural follow-up.

Out of scope (left for future PRs)

  • `gosec` MEDIUM findings on pre-existing code paths (sha1 in node-key hash, missing
    HTTP timeouts, etc.) — none introduced by this PR.
  • L2 govulncheck stdlib advisories (`GO-2026-5039`, `GO-2026-5037`) — reachable only
    via `cmd/tls/handlers/post.go` which this PR doesn't touch; bump Go version separately.

Test plan

  • `go test ./...` clean
  • `go build ./cmd/...` clean
  • `npm run check` (tsc) clean
  • `npm test` — 22 files / 106 tests all green after one selector fix for the chip-pad restyle
  • `npm run build` succeeds (846 KB bundle, pre-existing >500 KB warning unchanged)
  • `golangci-lint run` — 10 issues, all pre-existing on unchanged files; CI has
    `only-new-issues: true` so no new flags introduced
  • Manual smoke against a live dev instance (Proxmox VM at 192.168.99.118): the three
    new API endpoints return correctly substituted data for all 4 OSes; cert upload
    rejects malformed PEM with 400 and accepts a valid x509 with 200; assembled config
    GET returns the cached blob; H2 fix verified by attempting a deny as a non-admin
    and confirming a `denied access` row landed in `audit_logs`; M1 fix verified by
    a 100 KB body returning 400.
  • Fast-forward mergeable onto `upstream/main` (0 commits behind).

Add design doc for SPA parity PR covering:
- per-OS flags download (Linux/Mac/Windows/FreeBSD)
- certificate viewer/upload/download on enrollment page
- assembled osquery.conf view on config page
- interval sliders with live readout (replace number inputs)
- inline add-scheduled-query and add-option-flag forms
- per-section documentation links matching legacy
- 2 new osctrl-api endpoints (cert upload, configuration GET)
When a user rotates their own API token the new JWT used to invalidate
their own session cookie on the very next request (handlerAuthCheck
compares the JWT to user.APIToken; the cookie still held the previous
JWT). Server now detects self-rotate and re-issues the osctrl_token
and osctrl_csrf cookies with the freshly minted token + a new CSRF.
The SPA re-primes the in-memory CSRF from the rotated cookie so
subsequent mutations don't ship a stale X-CSRF-Token.

Other-user rotate is unchanged — the caller's own session is not
affected.
The four overlapping linearGradients (info/warning/signal/success at
0.55 → 0.18 opacity) made the queries and carves bands read muddy
where they sat under the upper layers. Stacked-area layers represent
additive amounts; gradients fight the visual hierarchy because the
'top of layer N' visually blends with 'bottom of layer N+1'.

Replace with solid CSS-token fills at fillOpacity=0.65. The top-of-stack
outline + gridlines provide all the depth this chart needs.
Both Queries (success) and Forensic Carves (info) cards rendered the
same signal-teal corner halo because the halo color was hardcoded to
--halo-r/g/b regardless of the tone prop. The toneText badge said one
thing, the halo glow said another — that's the 'looks strange' you'd
notice without being able to name it.

Drive the halo from a per-tone rgba map matching the existing
--{tone}-r/g/b token components so each card glows in its own color.
A stale in-memory CSRF (e.g. after a soft navigation that lost the
login response body, or a race during token rotation) used to make
every subsequent mutation 403 with 'csrf token missing or invalid'
until the user logged out and back in.

On any mutating request (POST/PUT/PATCH/DELETE) where
csrfTokenInMemory is null, fall through to primeCsrfFromCookie()
before deciding whether to attach X-CSRF-Token. The cookie is set
on every login and on every self-rotate, so as long as the browser
holds a fresh osctrl_csrf, the next mutation will carry the header.
The 24h stacked chart used --signal (teal #2bc4be) for queries and
--success (mint #4ade80) for config. Both colors live in the
green-teal quadrant; at flat 0.65 opacity over the dark surface they
read as nearly the same color and the layers blurred into each other.

Give config a chart-local violet (#a78bfa) — opposite teal on the
wheel, distinct from --info (blue) and --warning (amber). The color
override stays inside the TimeSeriesChart component; semantic --success
elsewhere is unaffected.
The query detail page lost the Targets table that legacy admin shows
under the SQL — operators couldn't see what scope the query was
launched against (platform/uuid/hostname/tag). The API returned 'target'
as an empty string because the detail is stored in the separate
query_targets table.

QueryShowHandler now also calls Queries.GetTargets(name) and includes
a 'targets' array on the response. The SPA renders them as compact
type:value chips below the SQL block. Best-effort: a targets-fetch
error doesn't fail the whole request.
TanStack Router's appRoute beforeLoad guard called isAuthenticated()
on every navigation. When csrfTokenInMemory transiently went null
(token rotation race, soft nav after a code path that called
setCsrfToken(null)) the guard threw a redirect to /login even though
the osctrl_csrf cookie was still valid. Selecting an env from the
EnvSwitcher hit this most often because it forces a route change.

Mirror the apiFetch fallback: when memory is empty, prime from the
cookie before deciding. Same security posture — we only read from
our own origin's cookie.
Adds the legacy admin's missing pieces to the SPA query log view:

  - Created column (relative time, full timestamp on hover)
  - Node column shows hostname when known, linked to /nodes/{uuid};
    falls back to a UUID prefix when the nodes lookup hasn't landed
  - Status column maps osquery distributed result codes 0/1/2 to
    ok/error/other badges
  - Refresh button that invalidates both the query meta and results
    queries — mirrors the legacy 'Refresh table' control

Nodes lookup uses a 60s-cached listNodes call so result-table renders
don't hit the API per row.
The corner halo was an absolute-positioned 32x32 disc that read as a
distinct shape — fine, but you noticed it looked image-like rather
than feeling like a real surface tint. Swap for a 135deg diagonal
linear-gradient from the tone color (at 0.22 alpha) into --bg-1 by
65% so the value text and delta chip still sit on a clean reading
surface. Drop the old halo <div> since the gradient now carries the
tone cue.
Previously 135deg poured the tone into the top-left, putting color
under the title and description. Flipping to 225deg keeps the value
column (left half) clean and tints the upper-right corner instead —
closer to the original halo's intent.
Three-layer Trace Map backdrop on the login screen:
  - Tiled circuit-board SVG (mask-image, theme-driven color) that
    drifts diagonally over 25s
  - Soft teal spotlight disc that translates around the viewport on
    a 12s loop with mix-blend-mode:screen so it brightens the
    circuit underneath
  - Static brand halo radial behind the card

Both animations respect prefers-reduced-motion.

Adds a sun/moon theme toggle button (fixed top-right, pre-auth) so
operators landing on a system in the wrong theme can flip without
logging in. Persists choice via the existing localStorage theme
helper.

The circuit.svg asset is the legacy admin's login circuit pattern
(from cmd/admin/static/img/), now color-tokenised via CSS mask so
dark and light themes share the same file.
The circuit SVG carried fill-opacity=0.06 from the original asset.
When used as a CSS mask, that alpha multiplied with the background
color's alpha, producing a near-invisible pattern (~0.6% effective
opacity instead of the preview's ~6%).

Strip the fill-opacity so the SVG renders as a clean solid mask;
move the actual opacity control into background-color alpha values
that match the preview HTML's perceived density.
Switch the login screen logo from the React <Logo> SVG to the original
PNG from cmd/admin/static/img/logo.png — the artwork legacy operators
already recognise (tower, broadcast arcs, trapezoidal cabin with
light-blue windows, mast and base). Inverted via the 'invert' utility
in dark mode so the near-black outline reads as light against the
dark card; left untouched in light mode where it pops naturally.

Replace the SPA's favicon.svg pointer in index.html with favicon.png
sourced from the same original artwork.
mix-blend-mode: screen on the spotlight disc works in dark theme
because there's plenty of room to brighten near-black. On light theme
the same blend mode tries to brighten a near-white surface and
produces no visible delta — the spotlight effectively disappears.

Override blend mode + opacity for light theme: multiply darkens what
the disc passes over (teal-tinted shadow), bumped alpha so the motion
is perceptible. Animation timings and the disc geometry stay the
same; only the painting behavior changes per theme.
The legacy osctrl tower mark (cmd/admin/static/img/logo.png) is what
operators already recognise. Replace the in-tree SVG drawing in the
Logo atom with an <img> tag pointing at the same PNG that the login
screen uses, and add a single .osctrl-logo CSS rule in base.css that
inverts the image on data-theme=dark.

LoginPage now uses the .osctrl-logo class as well so all surfaces
share the rule; no per-callsite invert conditionals.
Tile the legacy login screen's circuit-trace SVG behind the SideNav
rail so the brand carries from the login screen into the in-app shell
without competing with data on the main content area.

The existing subtle teal sheen at the rail's top is preserved by
stacking a linear-gradient on top of the SVG via background-image's
comma-separated layer syntax. Per-theme rules tune the SVG's
fill-opacity so dark and light read at similar perceived density.
Pattern is static — operators stare at the rail constantly and motion
would be a distraction.
Operators can now remap the 4 stacked-category colors (Config, Query,
Carve, Enroll) on the dashboard's time-series chart. Tap the palette
icon next to the 7-day/24-hour toggle to reveal a 4-input color row;
each input is a native <input type='color'> bound to the corresponding
series. Choices persist in localStorage so they survive reload.

Layers, top-of-stack outline, and legend swatches all read from the
same palette state, so a change updates the entire chart in one render.
A Reset button restores the original color scheme.

Also tones down the SideNav circuit pattern on light theme — was reading
too dark against the white rail at 0.32 fill-opacity, brought down to
0.18.
The in-SVG legend (Config / Query / Carve / Enroll circles+text at
the top of the chart) is now redundant with the always-visible
palette row above the chart — same swatch + label info, but each
swatch is a working color picker. Remove the SVG legend entirely
and drop the chart's top padding from 30 to 10 since there's no
legend strip to reserve space for.

The palette row also drops the collapse/expand toggle; it's a
permanent control like the day-range toggle next to it.
Load Bai Jamjuree (weights 500/700) from Google Fonts in index.html
with preconnect + display=swap. Add a small .font-wordmark utility
in base.css that uses it (falls back to Space Grotesk → sans-serif).

Apply only to the two wordmark surfaces — the login card header and
the SideNav rail — so the rest of the SPA's type stack (Inter / Space
Grotesk / IBM Plex Mono) is unchanged. The osctrl name now reads in
the same typeface operators see on the legacy admin.
filter: invert(1) flipped lightness correctly (dark outline → light)
but sent the light-blue cabin windows to a muddy orange because the
hue inverts too. Chain hue-rotate(180deg) after the invert so the
hue shift the inversion caused is cancelled — windows stay in the
cool/blue family, matching what operators see on the light theme.
The previous invert(1) hue-rotate(180deg) filter was being silently
mangled by Tailwind v4's CSS minifier — it dropped to invert()
hue-rotate(180deg), which is invalid CSS and the browser threw the
whole declaration away. So the dark-mode logo was rendering with no
filter at all (dark outline on dark card, no recolor).

Use invert(100%) instead — the percentage form survives minification.
Now the cabin windows keep their cool/blue tone in dark mode, matching
the light theme.
Tailwind v4's optimizer collapses invert(100%) to bare invert(),
which is invalid CSS and gets dropped — the dark-mode logo recolor
was silently a no-op. invert(99%) is visually identical but the
optimizer keeps the argument because it's no longer the default.
Two-variant approach replaces the filter:invert() hack that
Tailwind v4's optimizer kept silently breaking. osctrl-logo-dark.png
is the manually recolored mark — light-grey outline, brand-info blue
cabin windows — and base.css switches between the two PNGs based on
data-theme. Pixel-perfect control over both variants, no minifier
games to lose.

Also drops the dark-theme SideNav rail circuit from 14% to 8% alpha;
the previous value was reading too dense against the deep --bg-0.
The mockup the user shared (Heading 1 / 28px / Bold 700) shows the
intended wordmark in a plain sans-serif (Inter family). Bai Jamjuree
was the wrong call — operators want the SPA wordmark to stay in the
display stack the rest of the brand uses.

Reverts the Google Fonts import in index.html and rewrites
.font-wordmark to use Space Grotesk @ 700 with the same letter-spacing
as .font-display.
Re-add the Google Fonts <link> for Bai Jamjuree and point
.font-wordmark back at it. Previous deploy was supposed to do this
but the browser-rendered text never picked it up (likely a stale
asset cache). Trying again with a fresh bundle.
Google Fonts was blocked by the user's browser/network, so the
wordmark fell through to Space Grotesk and never rendered as
intended. Bundle the 3 Latin-subset weights (500/600/700) directly
in /fonts/ and replace the <link rel='stylesheet'> with local
@font-face rules. ~36KB total, no third-party CDN, no possible
blocker to defeat.
Extend EnvEnrollHandler switch with flagsLinux / flagsMac /
flagsWindows / flagsFreeBSD. Each substitutes the canonical install
paths for that OS into env.Flags's __SECRET_FILE__ and __CERT_FILE__
placeholders — same substitution legacy admin's download path
performs via generateFlags. SPA FlagsCard can now serve the same
per-OS downloads operators expect from the legacy UI.
POST /api/v1/environments/{env}/enroll/cert
Body: { "certificate_b64": "<base64-encoded PEM>" }

Decodes base64, parses the PEM CERTIFICATE block, and validates the
DER via x509.ParseCertificate before persisting via UpdateCertificate.
Legacy admin's equivalent path skipped validation; we add it so a
typo'd paste fails with a 400 instead of breaking enrollment
downloads later.

Audit-logged as 'upload certificate for environment <name>'.
The previous extruded-keys layout mangled multi-row osquery results.
item.data is { name, status, message, result: [...] } where result is
an array of result rows; the old code JSON.parse'd that and treated
{name, result} as the row, so every page rendered "name" and a
stringified array as outer columns.

Restored the legacy admin's nested-table-per-cell shape: outer table
is Created · Node · Data, and the Data cell holds a compact inner
table whose columns are the union of THIS result item's keys. A
processes query that returns 30 rows from one node now renders as
30 inner rows under one outer row instead of 30 sparse outer rows.

Status badge moved next to the Created timestamp so we keep room
for the nested table without an extra outer column.
…ec#6)

Full-width card on /env/{env}/enroll under FlagsCard:
- read-only Monaco (lazy-loaded via the shared CodeEditor) showing the
  exact JSON the TLS endpoint serves agents — wraps backend jmpsec#3's
  GET /environments/{env}/configuration/assembled, which re-runs
  RefreshConfiguration before reading so the result is always fresh
- Copy / Download (osctrl-{env}-config.json) / Refresh affordances
- byte-size badge in the header so heavy packs sections show up before
  download

Page vertical flow now reads metadata → install assets → flags →
config preview.
CarveDetailPage's file table rendered the node UUID as a plain
<span>; the legacy admin's carve-details view hyperlinks each row's
node to its detail page. Restore parity by wrapping the cell in a
TanStack Link to /_app/env/{env}/nodes/{uuid} and preferring hostname
when our nodes lookup has it (falls back to the truncated UUID).
Column header renamed Node UUID → Node since hostname now appears
when available.
Both buttons sat as TODO no-ops since the page first shipped. Wired
to POST /api/v1/nodes/{env}/delete (the only node-removal endpoint on
the API), which maps to pkg/nodes.ArchiveDeleteByUUID — same op the
legacy admin's node-actions handler dispatches for both archive and
delete. On success the page invalidates the nodes + node-detail
caches and navigates back to the env's Nodes table; failures surface
inline under the toolbar. confirm() prompts for now — a styled
ConfirmDialog is polish that can land later.
Three changes packaged together since they're all the same page:

1. IntervalsCard now renders three range sliders with the exact
   bounds from legacy admin's conf.html (Configuration / Logging
   10–600 step 10, Query 10–300 step 1) plus a centered numeric
   readout that mirrors the legacy <output> tag. Number-typing on
   the small input is clamped so slider + number stay in sync.

2. Page wrapped in a tab bar. "Settings" (intervals + expiration)
   is the default tab so the page loads cheap without six Monaco
   editors mounting on first paint. Each section tab carries a
   dirty-state dot so unsaved edits in non-active tabs stay
   visible. Editor height bumped 280 → 420px now that each section
   gets the whole viewport instead of stacking.

3. SideNav gains a Configuration entry under Enrollment (gated on
   canManageEnv, same as Enrollment) so the page is no longer only
   reachable via Cmd-K.
The page had absorbed CertificateCard, FlagsCard, and AssembledConfig
in the last two days and was now a 6-card vertical scroll that buried
the primary affordance (the install script). Reorganised into three
top-level tabs matching the three jobs operators come here for:

- Install (default): ScriptPanel front + a collapsible Pre-built
  Package URLs row tucked underneath
- Configuration: FlagsCard + AssembledConfigCard, stacked
- Lifecycle: 2-col LifecycleCards + full CertificateCard with
  inline replace — the destructive + secret-bearing surface,
  off the accidental-click path

Replaced the full-bleed NotAcceptingBanner with a compact inline
hint on the Install tab that has a button to jump to Lifecycle (where
Rotate reopens enrolls). The Lifecycle tab grows an amber dot in the
tab bar when enrolls are closed so the cue stays even after the user
dismisses the hint by switching tabs. Old NotAcceptingBanner func
deleted.
Same 7 options (All / Active / Inactive / Linux / macOS / Windows /
Other) wired exactly the same — only the chrome changed. Pills
inside a bordered grey segmented pad like StatusTabs, active state
uses bg-1 + shadow instead of a teal-tinted outline, so the env's
list pages share consistent visual language. Counts retained
(legitimate signal on this page; not present on Queries because
queries don't bucket by total).
QuickFiltersRow already renders All/Active/Inactive in the same
StatusTabs-style segmented pad as QueriesListPage — the second
inline triplet below was a leftover backup from before the chip
restyle and read as accidental duplication. Removed.

Also added the missing 'Nodes' h1 in the toolbar so the page matches
the Queries toolbar shape (title · search · page size).
The right column used to be one tall "Security" card stacking
password / token / theme / role text behind hairline dividers — high
cognitive load for what's really four discrete jobs.

Reorganised:
- Body is now a 2-col responsive grid (collapses to 1 on small)
  holding four equal cards: Account · Password · API token · Preferences
- Each card has one job — no more hairlines doing the work of headers
- Dropped the page subtitle ("Update your contact details, password,
  API token and theme preference") since the four card titles say
  the same thing without restating it
- Dropped the duplicate "Token expires" timestamp from the hero strip
  (it already lives inside the API token card)
- Dropped the trailing "Role: …" line — the hero badge already shows it
- Removed now-unused roleLabel variable
CarvesListPage renders title · status tabs · search · actions in a
single flex row. NodesTablePage had the chip pad on its own row
above, which read as a separate band of chrome. Moved the chip pad
inline into the toolbar so the page header is a single line matching
the rest of the env's list pages. Extracted QuickFiltersGroup (just
the segmented pad) from the old QuickFiltersRow which had row chrome
baked in.
Two changes to align the page with the SPA's other config surfaces:

- Service tabs now use the same TabButton underline pattern as
  EnvConfigPage and EnrollPage v3 (border-b-2 + signal accent +
  font-semibold on active) instead of the inset-shadow rounded chip
  with its own bg-2 fill. Service labels gain the 'osctrl-' prefix
  while we're at it so the tabs are self-describing.

- Each setting row gets EnvConfig's section chrome: rounded-md card,
  bg-0 header strip with name + type + info + pending badge + Save,
  body underneath. Per-row Save is intentionally preserved (the
  settings list is a flat catalogue of unrelated knobs; a global
  Save-all would be the wrong shape here).
jmpsec#8-jmpsec#10)

Three pieces of the parity spec land together since they all live on
EnvConfigPage's section headers/bodies:

jmpsec#8 AddScheduledQueryForm — inline three-field row above the Schedule
section editor (name, SQL, interval seconds). Parses the current
draft as a JSON object, appends the new query as
{name: {query, interval}}, re-serializes with 2-space indent, and
pushes it back into the draft state — same path Save section uses.
Refuses duplicate names; refuses to clobber unparseable drafts.

jmpsec#9 AddOptionForm — same shape above the Options section editor
with a name + value + type triple (string / integer / boolean).
Boolean values must be the literal strings 'true' or 'false' to
keep input parsing dumb-simple; integers parse as Number.

jmpsec#10 DocsLink atom — small ? glyph + dim 'docs' label that opens
the canonical osquery read-the-docs anchor for that section in a
new tab. Mounted in every section's header so an operator on the
Decorators tab can jump straight to upstream's decorator reference.
URLs target /latest/ deliberately — pinning would drift.
The bottom-centre 'Delete…' button on the multi-select dock was a
TODO no-op since the page first shipped. Wired to the existing
POST /nodes/{env}/delete endpoint via Promise.allSettled over the
selected UUIDs so a partial success surfaces 'deleted 7 of 9; 2
failed' instead of nuking the whole batch on the first 404.

confirm() prompts before the destructive op (matches the NodeDetail
pattern). Failures surface in a small dismissable danger toast at the
bottom centre once the selection clears so the user actually sees
the result.
TagsPage now carries the same multi-select dock as CarvesListPage
and NodesTablePage:
- Checkbox column with indeterminate-state header toggle
- Per-row checkbox + selected-row tint (--signal/5)
- Floating dock at bottom centre with row count + Delete + Clear
- Promise.allSettled over the selected names so a partial failure
  reports 'deleted N of M; X failed' instead of nuking the batch
- Dismissable danger toast for the bulk result when selection clears

Per-row Edit / Delete buttons preserved — same pattern as Carves keeps
per-row Open.
Same dock pattern as TagsPage/CarvesListPage with one safety twist:
the current operator's own row gets a non-selectable spacer in the
checkbox cell instead of a checkbox, and the header select-all only
operates on the deletable subset. If the only visible user is the
current operator, the header checkbox is disabled rather than
misleading. Server-side guard against self-delete remains the
authoritative gate.

Per-row Permissions / Token / Reset password / Delete buttons stay
for one-off use — same shape Tags preserves.
Same dock pattern as TagsPage / UsersPage / NodesTablePage. The
confirm prompt calls out that the backend's Environments.Delete is
a hard DB delete with no cascade check — nodes / queries / carves /
tags scoped to the deleted env will be orphaned. Per-name
Promise.allSettled so a failing env doesn't block the rest of the
batch.
The bare <a target=_blank> form rendered as un-clickable in the live
build — hover showed the title tooltip but left-click did nothing.
Likely nginx CSP / rel=noopener rewriting interfering with the bubble.

Belt-and-suspenders: explicit onClick handler calls window.open with
the noopener/noreferrer feature string. cmd/ctrl/shift/middle clicks
still fall through to the browser's own new-tab behaviour via the
preserved href + target. Added cursor-pointer + title attribute so
the affordance is unambiguous.
Two bugs in one shot:

1. The window.open feature string 'noopener,noreferrer' (3rd arg)
   triggers Chromium's popup heuristic, which collapses the target
   argument and opens about:blank. Correct path is window.open(href,
   '_blank') with no feature string, then explicitly null
   win.opener to keep noopener semantics. href guarded for null
   so we don't open empty tabs.

2. The Enroll page had no DocsLink anywhere — spec jmpsec#10 only added
   them on EnvConfigPage sections. Added one DocsLink per Enroll
   tab pointing at the most relevant osquery doc:
   - Install tab → deployment/remote/
   - Configuration tab → cli-flags/ + deployment/configuration/
   - Lifecycle tab → deployment/remote/#tls-server (enroll secret
     + cert pinning live there)
The two-button shape on NodeDetailPage looked like soft vs hard but
mapped to the same backend op (ArchiveDeleteByUUID always snapshots
into archive_osquery_nodes THEN removes the live row). Operators
reasonably read that as 'Archive is reversible' even though no
restore UI exists — a real incident pattern.

Replaced both buttons with a single 'Archive' button on the detail
page and renamed the bulk dock action to 'Archive…'. Confirm copy
calls out the snapshot explicitly. State / mutation / handler names
renamed deleteMut → archiveMut, bulkDeleteMut → bulkArchiveMut,
handleBulkDelete → handleBulkArchive. canDeleteNode permission gate
stays as-is (it's checking env-admin perms, not labelling).
H1 — EnvConfigurationHandler was a GET that wrote to the DB via
RefreshConfiguration. CSRF-via-GET shape and a hot-loop DB-write
hazard when React-Query's stale refetch path hits it. Dropped the
RefreshConfiguration call — every parts mutation (UpdateOptions /
UpdateSchedule / UpdatePacks / UpdateDecorators / UpdateATC /
UpdateFlags + their bulk siblings) already fires RefreshConfiguration
inside pkg/environments/osqueryconf.go (8 call sites), so the
cached env.Configuration is always current at read time. GET now
serves env.Configuration directly with no side effect.

H2 — All 7 permission-deny sites in environments_crud.go used
apiErrorResponse instead of denyEnv. denyEnv writes an
AuditLog.Denied row; apiErrorResponse does not. The older
environments.go file routes every deny through denyEnv, so the
new CRUD endpoints had an audit-log blind spot vs the rest of the
admin surface — defenders' alert rules for 'cross-tenant probe
attempts' would miss anything hitting the new endpoints.
Replaced all 7 sites with denyEnv. Pre-env-resolution sites pass
auditlog.NoEnvironment; env-resolved sites pass env.ID. Imports
gain auditlog package.
M1 — EnvCertUploadHandler now wraps r.Body with MaxBytesReader at
64 KiB before json.NewDecoder. Real PEM chains are under 16 KiB;
the cap leaves headroom while preventing a compromised admin token
from being used to OOM the API (~1.33x JSON decode + 0.75x base64
decode amplification on the 20 MiB nginx-allowed body would
otherwise hit ~35 MiB resident per request).

M2 — AddOptionForm and AddScheduledQueryForm now refuse __proto__ /
constructor / prototype as option/query names. Hoisted the
RESERVED_OPTION_NAMES set to module scope so both forms share it.
Without this guard parsed['__proto__']=v mutates the local prototype
chain instead of creating an own property, the JSON.stringify
silently drops the entry, and the operator retries forever. Not a
security finding in the current code paths but a footgun if anyone
copies the form pattern into a path that consumes parsed.

M3 — substitutePlatformPaths uses strings.ReplaceAll instead of
strings.Replace(..., 1). The flag template has one occurrence of
each placeholder today; the single-replace defaults to the legacy
admin's behaviour. Defensive change in case a future operator
references a path twice in the template — silent unsubstituted
placeholders are an outage-class footgun.
The 'clicking a status tab calls listNodes' test was anchored to a
button labelled 'active' from the old inline status pad triplet
that lived alongside the chip row. Commit cf79acf dropped that
duplicate triplet, leaving QuickFiltersGroup's chips as the single
source of status filtering. Each chip exposes itself via
aria-label='Filter: Active' instead of bare text 'active', so the
test selector now matches /^filter: active/i.

Verified all 22 test files / 106 tests pass after the change.
The branch shipped one internal planning markdown
(docs/superpowers/specs/2026-05-26-enroll-config-parity-design.md)
that doesn't belong in an upstream contribution. Removed from the
tree and added docs/superpowers/ to .gitignore so future tree
pollution stays local. The spec served its purpose (steered the
work in this PR); the implementation is the deliverable.
@javuto javuto added the osctrl-api osctrl-api related changes label Jun 10, 2026
@javuto javuto self-requested a review June 10, 2026 05:46
@javuto javuto added the ⭐️ frontend Frontend related issues label Jun 10, 2026
@javuto

javuto commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Let's fucking goooooooo! 🚢

@javuto javuto merged commit bfa4906 into jmpsec:main Jun 10, 2026
3 checks passed
@alvarofraguas alvarofraguas deleted the pr/enroll-config-parity branch June 10, 2026 06:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⭐️ frontend Frontend related issues osctrl-api osctrl-api related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants