swag v2.0.0-rc5's parseSecAttributesV3 resolves a security scheme's map key
via getSecurityDefinitionKey(lines), which scans from the start of whatever
comment-line slice it was handed and returns the first @securitydefinitions
match — ignoring the current parse position entirely. Three
@securityDefinitions.apikey blocks stacked in one Go comment group (the
three were separated only by bare '//' lines, which do not split an
ast.CommentGroup) therefore all resolved to the first block's name
(cookieAuth), with the last block's in/name/description winning: the
generated document had exactly one securityScheme, keyed cookieAuth, body
esoAuth.
Separating the three blocks with real blank source lines splits them into
three distinct ast.CommentGroups, so swag's file-level comment scan (which
requires no other tokens between them, same rule Go uses for doc comments)
hands each block its own line slice and each resolves its own key.
Regenerated openapi.json now carries all three schemes with correct
bodies, referenced with no dangling security requirements.
Generated from swaggo v2 annotations, committed rather than built into the
image: the runtime stage is scratch and adding codegen puts the toolchain
in the build. CI regenerates and diffs, so an annotation edited without
regenerating fails the build — without that the annotations would drift
while still looking authoritative.
Scalar is vendored rather than loaded from a CDN, because air-gapped
self-hosted installs are supported and a reference page that fails closed
offline is a support ticket.
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. This is the last of the handler files under
server/internal/api/.
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. VulnSummaryResponse uses pointer fields so the
db-freshness block stays entirely absent when no vulndb_meta document
exists yet, matching the handler's original conditional gin.H exactly.
Converts their gin.H responses to the named types added in the previous
commit and adds swaggo doc blocks for every handler in handlers.go and
tokens.go.
Named response types for handlers that were returning anonymous gin.H
literals, so a generated annotation and what the handler actually returns
cannot disagree. main.go carries the top-level swaggo info block (title,
description, security schemes for cookie, bearer token and ESO auth).
A card in the Access group beside Members and single sign-on rather than a
new nav entry — /settings/instance was folded back in for exactly this
reason. The plaintext is shown once in a well block and never again.
Tokens outside a newly tightened lifetime policy are flagged rather than
broken, because the policy governs issuance, not existing credentials.
600 per minute per token, in the Redis that sessions already require.
Cookie sessions are untouched. A Redis failure falls through rather than
refusing traffic — it is already a larger problem and should not become a
second outage.
createToken's catch-all mapped every unmatched error to 400, so a
database outage reported itself as a malformed client request. Wrap the
genuine validation failures with ErrTokenInvalid and let the handler
answer 500 with a fixed message for everything else.
createToken's catch-all answered 400 for every unmatched error, so a
database failure reported itself as the caller's malformed request. Found
in review of Task 7.
Create, list and revoke, with no update: editing what a credential already
deployed in CI can do, with no record of what it could do before, is worse
than requiring a rotation. Revoking a token that is not yours answers
not-found, since a 403 confirms it exists.
The audit actor stays the human and names the credential alongside, so a
person clicking and their CI job are told apart.
Keyed on the registered gin route pattern rather than a per-route
decorator, because a route registered without a decorator would be
unguarded. An unmapped route reached by a token is a 403, and a boot-time
check refuses to start when any /api route is missing, so the failure
lands at deploy rather than as a customer's surprise 403.
sessionFromCookie already writes "session expired" when a cookie was
presented and rejected with no bearer to fall through to. Middleware
called sessionFromToken anyway, which wrote a second "not authenticated"
body onto the same response for every ordinary browser-session timeout -
gin logged "superfluous response.WriteHeader call" on ordinary use, not a
rare edge case.
Guard on c.IsAborted() after sessionFromCookie: true only in that one
rejected-cookie-no-bearer branch, so it short-circuits there while the
other three credential paths (no credential, bearer only, stale cookie
plus valid bearer) are unaffected.
The plan's Middleware called sessionFromToken even when sessionFromCookie
had already answered a rejected cookie, putting two JSON bodies on the
wire for the ordinary expired-session case. Found in review of Task 5.
One middleware, two ways to arrive at the same *Session, so every handler,
role guard, licence gate and audit call is untouched. The host guard
applies to both: a token carries an instance, and the tenant boundary must
not have a token-shaped hole in it.
The effective role is min(user, token) recomputed per request, so demoting
somebody demotes their tokens with them. A stale cookie beside a valid
bearer falls through rather than refusing a credential that would work.
Mint, resolve, list and revoke, with the effective role capped at the
owner's and recomputed per request rather than frozen at creation.
Deleting a user deletes their tokens in the same call, so offboarding is
one action. Revoking somebody else's token answers not-found rather than
forbidden, since a 403 confirms the credential exists.
Also re-exports shared.APITokenMaxDays into server/internal/models,
following the existing ValidRole wrapper pattern, since the token
service needs it and it was never re-exported.
A pointer with absent meaning no cap, so an upgrade allows never-expire
tokens exactly as before and an instance opts into the policy. It governs
issuance only: changing it never invalidates a token that already exists.
Eight resources with read and write, write implying read. Coarse on
purpose: a scope per endpoint is a table nobody maintains, and a route
added without an entry either breaks or is unguarded.
The unique index on token_hash is what makes authentication an indexed
lookup rather than a scan, so this builder is fatal on failure like
EnsureAuthIndexes rather than warning like the secrets one.
Registered in ScopedCollections so instance purge reaches it.
Twelve tasks from model through middleware, scope enforcement, endpoints,
web UI, generated OpenAPI and documentation. Verification is build plus
curl and UI checks rather than test cycles, matching a repository with no
Go test harness beyond shared/mail.
Adds the approved design for personal access tokens on the control plane
REST API, and for the generated OpenAPI 3.1 document served as a Scalar
reference page.
Tokens fall back into the existing session middleware rather than getting
their own route group, so every handler, role guard and audit call works
unchanged. Scope enforcement derives from the registered route pattern and
fails closed, with a boot-time check for unmapped routes.
A Terraform provider is deliberately left to a follow-on spec.
The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.
Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.
Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.
audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.
Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
The last of the inline two-step deletes, and the one with the most reach: a
key delete revokes it from every server at once, and for a generated key the
stored private half goes with it. That copy is the only one Vantage holds, so
unlike an uploaded key this cannot be undone by pasting the public half back.
Body names how many servers lose access, and says the private key is destroyed
only when there is one to destroy.
The delete error moves out of the toast and into the dialog, which stays open
on failure, matching the server and monitor deletes.
Destructive actions were confirmed by a second danger button rendered where
the first one had been, so a double click on Remove deleted the thing without
the operator ever reading which thing it was. Servers, monitors, secret keys
and sign-in providers now go through ConfirmDialog with requireTyped, matching
the secret-group delete that already worked this way. Notification channels and
vulnerability alert rules get an untyped dialog: both are a name and a URL and
are rebuilt in a minute, but neither had any confirmation at all, and both
silently stop alerts that nobody misses until an incident goes unannounced.
Mutations otherwise succeeded in silence, or reported into whatever inline
banner the page happened to own. Two failure modes came of that: a modal that
closed on error left the message nowhere to land, and a save that was rejected
left the old values on screen looking exactly like a save that worked
(/settings had no error path at all). Every mutation now reports through the
existing toast context.
Errors stay inline where the surface that raised them is still on screen and
the message is a correction to make in it: form validation, the cron field,
the tag rows, a rejected licence blob, and the workflow designer's autosave,
which is a standing condition rather than an event. Everything else toasts.
Ad-hoc feedback removed in favour of it: the "Sent!" button labels on the
server maintenance tab, the settings "Saved!" flag, the channel test line, and
the steps page's notice/error pair.
Both from the final review, and the first one overturns a call I got wrong.
The aria-hidden sweep that makes aria-modal true also swallowed the toasts.
ToastProvider renders inside the app root, and every modal-raised confirmation
is toasted *before* its dialog closes — "Saved …", "Deleted …", "Removed …" —
so each one was inserted into a hidden subtree and never announced. Un-hiding
a live region afterwards does not replay what it missed. The toast layer is
portalled to the body carrying the dialog-layer attribute, which exempts it
from the sweep, and sits above the dialog: a toast explaining why a dialog's
action failed is no use behind it.
EmptyState's action was narrower than the call site it replaced. The old
first-workflow button carried loading={isPending}; the new one carried
nothing, so a double click created two workflows. The action is a union now —
a link takes no pending state, a handler takes loading and disabled.
Keys, secrets, audit, steps and workflows each carried the same
loading/error/empty ternary with its own copy of the spinner and its own
wording for a failed fetch — five of them had drifted to five different
sentences for "the request did not come back". They go through AsyncBoundary
now, which means a skeleton in place of a spinner, a retry button on failure,
and backend messages passed through friendlyMessage rather than printed raw.
Steps gets the filtered-empty state the fleet just got: "no steps match that
filter" is not "no steps yet", and only one of them should offer to create the
first one.
Two more from review:
Modal's effect ran before `mounted` flipped, so a dialog rendered already open
found null refs and took no focus at all. It depends on `mounted` now.
aria-modal was a claim with no mechanism behind it — portalled to the body,
the app tree is a sibling of the dialog and a screen reader's virtual cursor
still browsed the page underneath. The body's other children are marked
aria-hidden while any dialog is open, refcounted alongside the scroll lock.
This does mean a toast raised while a dialog is open is not announced, which
is the correct trade for a modal: ConfirmDialog shows its own errors inline.