Compare commits
21
Commits
449684ceaa
...
4c88d6e768
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c88d6e768 | ||
|
|
a85a354e57 | ||
|
|
9b18d09d9b | ||
|
|
a398da0eac | ||
|
|
edb8406e05 | ||
|
|
bfd185adbb | ||
|
|
182752d9ab | ||
|
|
3b4c87a292 | ||
|
|
2685e9ad06 | ||
|
|
4de67e4bea | ||
|
|
524ccc6412 | ||
|
|
be4f488db3 | ||
|
|
2f60b81962 | ||
|
|
92692de94d | ||
|
|
a5f9fca59e | ||
|
|
72e5228351 | ||
|
|
33b5ec0788 | ||
|
|
1b718e7c59 | ||
|
|
6ad65a1242 | ||
|
|
a41f2b26cc | ||
|
|
71f9a9dca5 |
@@ -95,6 +95,24 @@ jobs:
|
||||
docker login ${{ vars.DOCKER_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Verify the OpenAPI document is current
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
|
||||
cd server
|
||||
swag init --generalInfo cmd/main.go --dir ./,../shared \
|
||||
--output internal/api/docs --outputTypes json --v3.1
|
||||
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
|
||||
git diff --exit-code internal/api/docs/openapi.json
|
||||
|
||||
- name: Build and push server image
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
# API tokens and OpenAPI reference
|
||||
|
||||
Date: 2026-08-12
|
||||
Status: approved, ready for implementation planning
|
||||
|
||||
## Problem
|
||||
|
||||
The only programmatic credential the control plane issues is the ESO secrets-read
|
||||
bearer token, which reaches exactly one endpoint. Everything else requires a
|
||||
browser session cookie. There is therefore no supported way to drive Vantage from
|
||||
CI, a script, or infrastructure-as-code, and no machine-readable description of
|
||||
the REST API for anyone who wants to try.
|
||||
|
||||
This spec covers two deliverables that ship together: scoped API tokens, and an
|
||||
OpenAPI 3.1 document rendered as a live reference page. A Terraform provider is
|
||||
the intended follow-on and is explicitly out of scope here — it depends on both
|
||||
of these being settled, and it is a separate Go module with its own release
|
||||
cycle.
|
||||
|
||||
## Goals
|
||||
|
||||
- A person can mint a scoped, optionally expiring token and use it against the
|
||||
existing REST API with no new endpoints to learn.
|
||||
- A leaked token is bounded by role, by scope, and by expiry policy.
|
||||
- Offboarding a person removes their tokens as a side effect of removing them.
|
||||
- The API has a machine-readable description that cannot silently drift from the
|
||||
handlers it describes.
|
||||
- The reference page works on an air-gapped self-hosted install.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Token editing. Role and scopes are immutable; rotation replaces amendment.
|
||||
- OAuth device flow or any browser-based authorisation grant.
|
||||
- Per-server or per-tag restrictions on a token.
|
||||
- Instance-owned service tokens that outlive their creator.
|
||||
- The Terraform provider.
|
||||
- General API rate limiting beyond the per-token limit described below.
|
||||
|
||||
## Part 1 — API tokens
|
||||
|
||||
### Token format and storage
|
||||
|
||||
A token is `vt_` followed by 32 random bytes, hex encoded. It is displayed once,
|
||||
at creation, and never again.
|
||||
|
||||
Only the SHA-256 hash is stored, in a unique index. This follows the precedent
|
||||
already set by `servers.agent_token_hash` and the ESO read token. bcrypt is
|
||||
deliberately not used: the value is full-entropy random rather than a
|
||||
user-chosen password, so a fast hash is sufficient, and a per-token salt would
|
||||
force a collection scan where an indexed lookup is wanted.
|
||||
|
||||
The first eight characters are stored in clear as `hint`, so the list can
|
||||
identify a token without revealing it.
|
||||
|
||||
### Authentication path
|
||||
|
||||
`auth.Middleware()` gains a fallback. When there is no `km_session` cookie it
|
||||
looks for `Authorization: Bearer vt_…`. Both paths end by placing a `*Session` in
|
||||
the gin context, so every handler, `auth.RequireRole`, `RequireActiveLicense`,
|
||||
`RequireFeature` and `actorFromCtx` continue to work unmodified.
|
||||
|
||||
```
|
||||
Session{
|
||||
UserID: token.UserID
|
||||
InstanceID: token.InstanceID
|
||||
Role: min(user.Role, token.Role) // owner > admin > member
|
||||
Email: user.Email
|
||||
TokenID: token.TokenID // "" for cookie sessions
|
||||
Scopes: token.Scopes // nil for cookie sessions
|
||||
}
|
||||
```
|
||||
|
||||
The effective role is recomputed on every request rather than frozen at
|
||||
creation. Demoting the user demotes the token with them. No caching is required
|
||||
because the user document is already read to confirm the user still exists.
|
||||
|
||||
The existing host guard applies identically. A token carries an `instance_id`,
|
||||
and a request arriving at a different instance's host is rejected exactly as a
|
||||
mismatched cookie session is. The tenant boundary must not have a token-shaped
|
||||
hole in it.
|
||||
|
||||
`last_used_at` is written best-effort and only when the stored value is more
|
||||
than 60 seconds old, so it does not become a Mongo write per request.
|
||||
|
||||
Rejections:
|
||||
|
||||
| Condition | Status | Body |
|
||||
| -------------------- | ------ | -------------------------------------- |
|
||||
| No credential at all | 401 | `not authenticated` |
|
||||
| Unknown token | 401 | `invalid token` |
|
||||
| Expired token | 401 | `code: token_expired` |
|
||||
| Owning user deleted | 401 | `invalid token` |
|
||||
| Missing scope | 403 | names the required scope |
|
||||
| Wrong instance host | 403 | `instance host mismatch` |
|
||||
|
||||
### Data model
|
||||
|
||||
New collection `api_tokens`, added to `services.ScopedCollections` so instance
|
||||
purge reaches it.
|
||||
|
||||
```
|
||||
instance_id string
|
||||
token_id string
|
||||
user_id string
|
||||
name string 1-64 chars, unique per user
|
||||
hint string first 8 chars of the plaintext
|
||||
token_hash string sha256
|
||||
role string owner|admin|member
|
||||
scopes []string
|
||||
expires_at *time.Time nil means never
|
||||
created_at time.Time
|
||||
last_used_at *time.Time
|
||||
created_by_ip string
|
||||
```
|
||||
|
||||
Indexes: unique on `token_hash`; compound on `(instance_id, user_id)`.
|
||||
|
||||
Deleting a user deletes their tokens as part of the same service call as
|
||||
`DeleteInstanceUser`, so offboarding is one action rather than two.
|
||||
|
||||
### Expiry policy
|
||||
|
||||
Expiry is optional by default: a token may be created with no expiry at all.
|
||||
Instance settings gain `api_token_max_days *int`, editable by owner and admin:
|
||||
|
||||
- `nil` — no cap; never-expire is allowed. This is the default, so an upgrade
|
||||
changes nothing.
|
||||
- `n > 0` — a new token must expire within `n` days, and a never-expire token is
|
||||
refused.
|
||||
|
||||
Changing the setting does not retroactively invalidate existing tokens; it is a
|
||||
policy on issuance. Tokens already outside the new cap are flagged in the UI so
|
||||
that someone can rotate them deliberately, rather than discovering the change
|
||||
when a pipeline breaks.
|
||||
|
||||
### Scopes
|
||||
|
||||
Eight resources, each with `:read` and `:write`. Write implies read on the same
|
||||
resource.
|
||||
|
||||
```
|
||||
servers keys secrets workflows
|
||||
monitors vulns workloads settings
|
||||
```
|
||||
|
||||
Scope enforcement is a single middleware, `RequireScopes()`, mounted once in the
|
||||
`/api` stack. It derives the required resource from the matched gin route
|
||||
pattern using a map, rather than from a per-route decorator: a route registered
|
||||
without a decorator would otherwise be unguarded, and this repo already prefers
|
||||
guards that come from where a route is mounted rather than from someone
|
||||
remembering.
|
||||
|
||||
- Cookie sessions skip the check entirely.
|
||||
- A token-authenticated request whose route pattern is absent from the map is
|
||||
denied with 403. Fail closed.
|
||||
- A startup check fails boot if any registered `/api` route pattern is missing
|
||||
from the map, so the failure surfaces at deploy rather than at the first call.
|
||||
|
||||
Deliberate placements:
|
||||
|
||||
- `keys:read` covers `GET /keys/:id/private-key`. Reading a private key is
|
||||
reading a key.
|
||||
- `secrets:read` does not cover `GET /api/secrets/:group/values`. That endpoint
|
||||
keeps its separate ESO bearer path and is unaffected by this work.
|
||||
- `workloads:write` covers both container control actions and log reads, which
|
||||
are already restricted to owner and admin.
|
||||
- The token endpoints themselves map to the `settings` resource: `GET
|
||||
/api/tokens` requires `settings:read`, and `POST` and `DELETE` require
|
||||
`settings:write`. A token can therefore mint or revoke tokens only when
|
||||
explicitly granted that scope, and never above its own role.
|
||||
|
||||
### Endpoints
|
||||
|
||||
```
|
||||
GET /api/tokens list; a member sees their own, owner|admin see all
|
||||
POST /api/tokens create; returns the plaintext once
|
||||
DELETE /api/tokens/:id revoke; own always, owner|admin any
|
||||
```
|
||||
|
||||
There is no `PUT`. Editing a token's role or scopes changes what a credential
|
||||
already deployed in a CI system can do, with no record of what it could do
|
||||
before. Rotation replaces amendment.
|
||||
|
||||
`POST` body: `name`, `role`, `scopes[]`, `expires_in_days` (omitted means never,
|
||||
and is refused when `api_token_max_days` is set).
|
||||
|
||||
Refusals: 400 for an unknown scope, 409 for a duplicate name for that user, 403
|
||||
for a role above the creator's own, 422 for an expiry beyond policy.
|
||||
|
||||
### Web UI
|
||||
|
||||
A new "API tokens" card in the Access group of `/settings`, alongside Members
|
||||
and single sign-on. Not a new nav entry — `/settings/instance` was folded back
|
||||
into `/settings` for precisely this reason, and the card lives in
|
||||
`web/components/settings/` with the others, reusing the shared `Field` and
|
||||
`inputClass`.
|
||||
|
||||
The card lists name, hint, role, scope chips, last used, and expiry with a
|
||||
distinct state for expired and for over-policy. Revoke is per row and confirms.
|
||||
|
||||
Create opens a modal. The plaintext is shown once in a `--well` block with
|
||||
copy-to-clipboard and an explicit line saying it will not be shown again.
|
||||
|
||||
Members see only their own rows. Owner and admin get an "All tokens" toggle.
|
||||
|
||||
`api_token_max_days` is a field on the same card, visible to owner and admin
|
||||
only.
|
||||
|
||||
### Audit
|
||||
|
||||
New events:
|
||||
|
||||
- `token.created`
|
||||
- `token.revoked`
|
||||
- `token.expired_use` — a rejected expired token, which is how a forgotten CI
|
||||
job becomes visible
|
||||
- `settings.token_policy_updated`
|
||||
|
||||
The actor is the human's email throughout, so `actorFromCtx` needs no change.
|
||||
Every existing audit event written during a token-authenticated request gains
|
||||
`via: "token:<name>"` in its detail, so the log distinguishes a person clicking
|
||||
from their credential acting.
|
||||
|
||||
### Rate limiting
|
||||
|
||||
Token-authenticated requests are limited per token in Redis at 600 per minute,
|
||||
answering 429 with `Retry-After`. Cookie sessions are untouched. This is narrow
|
||||
on purpose: it is not the general API rate-limiting project, only enough that a
|
||||
runaway script cannot take an instance down.
|
||||
|
||||
## Part 2 — OpenAPI and the reference page
|
||||
|
||||
### Generation
|
||||
|
||||
`swaggo/swag` v2, pinned, emitting OpenAPI 3.1. v1 emits Swagger 2.0, which
|
||||
Scalar renders poorly.
|
||||
|
||||
Handlers in `server/internal/api/*.go` gain annotation comments. Request and
|
||||
response bodies that are currently anonymous inline structs become named
|
||||
structs. This is real churn across roughly fifteen files and is the honest cost
|
||||
of choosing generation over a hand-written document.
|
||||
|
||||
The generated `server/internal/api/docs/openapi.json` is committed and embedded
|
||||
with `go:embed`, not generated during the image build: `server/Dockerfile`
|
||||
produces a `scratch` runtime from a Go build stage, and adding codegen there
|
||||
means putting the toolchain in the build image.
|
||||
|
||||
`server-deploy.yml` gains a check that regenerates the spec and runs
|
||||
`git diff --exit-code`. An annotation edited without regenerating fails the
|
||||
build. Without this check the annotations are worth less than a hand-written
|
||||
document, because they would drift while appearing authoritative.
|
||||
|
||||
### Serving
|
||||
|
||||
```
|
||||
GET /api/openapi.json the spec, session or token authenticated
|
||||
GET /api/docs HTML page loading a vendored Scalar bundle
|
||||
```
|
||||
|
||||
The Scalar standalone bundle is vendored under `server/internal/api/docs/`, with
|
||||
its version recorded in a comment beside it and refreshed by hand. No CDN:
|
||||
air-gapped self-hosted installs are supported, and a reference page that fails
|
||||
closed on an offline site is a support ticket.
|
||||
|
||||
Because the page is served by the instance itself, "Try it" acts against the
|
||||
reader's own API with their own session.
|
||||
|
||||
### Documented auth schemes
|
||||
|
||||
Three, kept distinct:
|
||||
|
||||
- `cookieAuth` — the `km_session` cookie.
|
||||
- `bearerAuth` — a `vt_…` API token.
|
||||
- The ESO secrets endpoint is marked as its own separate scheme, so nobody wires
|
||||
a personal access token into External Secrets Operator.
|
||||
|
||||
## Documentation
|
||||
|
||||
- `docsite/docs/reference/api-tokens.md`: creating a token, the scope table,
|
||||
curl examples, rotation, and the maximum-lifetime policy.
|
||||
- `CLAUDE.md`: the three token routes under REST API, the `api_tokens`
|
||||
collection, and a note that `openapi.json` is generated and CI-verified.
|
||||
|
||||
## Risks
|
||||
|
||||
- The anonymous-struct-to-named-struct conversion is the bulk of the work and
|
||||
touches handler code this feature otherwise has no business in.
|
||||
- The vendored Scalar bundle is a manual refresh that nobody will remember. The
|
||||
version comment is the only mitigation.
|
||||
- A scope map keyed on gin route patterns breaks if a route path is renamed. The
|
||||
boot-time completeness check is what turns that into a startup failure rather
|
||||
than a silent 403 in production.
|
||||
|
||||
## Follow-on work
|
||||
|
||||
A Terraform provider, as its own spec and plan, consuming the tokens and the
|
||||
OpenAPI document produced here.
|
||||
@@ -29,6 +29,24 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// @title Vantage API
|
||||
// @version 1.0
|
||||
// @description The Vantage control plane REST API. Authenticate with a browser session cookie, or with an API token created under Settings → API tokens.
|
||||
// @BasePath /api
|
||||
//
|
||||
// @securityDefinitions.apikey cookieAuth
|
||||
// @in cookie
|
||||
// @name km_session
|
||||
//
|
||||
// @securityDefinitions.apikey bearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
|
||||
//
|
||||
// @securityDefinitions.apikey esoAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
|
||||
func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
|
||||
@@ -109,6 +127,10 @@ func runSchemaSetup() {
|
||||
log.Fatalf("failed to ensure auth indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureAPITokenIndexes(); err != nil {
|
||||
log.Fatalf("api token indexes: %v", err)
|
||||
}
|
||||
|
||||
// 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id)
|
||||
// index must exist before anything inserts providers, or a concurrent
|
||||
// re-run could double-insert before the index is there to refuse it.
|
||||
@@ -230,6 +252,10 @@ func serve() {
|
||||
r.Use(corsMiddleware())
|
||||
api.RegisterRoutes(r)
|
||||
|
||||
if err := api.AssertScopeMapComplete(r); err != nil {
|
||||
log.Fatalf("api scope map: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Addr: ":8080", Handler: r}
|
||||
go func() {
|
||||
log.Println("REST server listening on :8080")
|
||||
|
||||
@@ -26,10 +26,30 @@ func viewOf(c *gin.Context, p models.AuthProvider) authProviderView {
|
||||
}
|
||||
}
|
||||
|
||||
// listAuthPresets godoc
|
||||
//
|
||||
// @Summary List SSO presets
|
||||
// @Description Preset providers (Entra, Google, Okta, GitHub) that expand to a real issuer on save.
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Success 200 {array} auth.Preset
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/presets [get]
|
||||
func listAuthPresets(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, auth.Presets())
|
||||
}
|
||||
|
||||
// listAuthProviders godoc
|
||||
//
|
||||
// @Summary List SSO providers
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Success 200 {array} authProviderView
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers [get]
|
||||
func listAuthProviders(c *gin.Context) {
|
||||
providers, err := services.ListAuthProviders(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -43,6 +63,18 @@ func listAuthProviders(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// createAuthProvider godoc
|
||||
//
|
||||
// @Summary Create an SSO provider
|
||||
// @Tags auth-providers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{name=string,preset=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool} true "Provider parameters"
|
||||
// @Success 201 {object} authProviderView
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers [post]
|
||||
func createAuthProvider(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
@@ -80,6 +112,22 @@ func createAuthProvider(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, viewOf(c, *p))
|
||||
}
|
||||
|
||||
// updateAuthProvider godoc
|
||||
//
|
||||
// @Summary Update an SSO provider
|
||||
// @Tags auth-providers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Param body body object{name=string,issuer_input=string,client_id=string,client_secret=string,enabled=bool,order=int} true "Fields to update"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id} [put]
|
||||
func updateAuthProvider(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
@@ -135,9 +183,23 @@ func updateAuthProvider(c *gin.Context) {
|
||||
// document was built from the old ones.
|
||||
auth.EvictProvider(providerID)
|
||||
services.LogEvent(instanceID, "auth_provider.update", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// deleteAuthProvider godoc
|
||||
//
|
||||
// @Summary Delete an SSO provider
|
||||
// @Description Refused when the instance would be left with no way in (no local login and no other enabled provider).
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id} [delete]
|
||||
func deleteAuthProvider(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
providerID := c.Param("id")
|
||||
@@ -161,7 +223,7 @@ func deleteAuthProvider(c *gin.Context) {
|
||||
}
|
||||
auth.EvictProvider(providerID)
|
||||
services.LogEvent(instanceID, "auth_provider.delete", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// guardProviderChange asks whether the instance would still have a way in.
|
||||
@@ -178,6 +240,18 @@ func guardProviderChange(instanceID string, existing *models.AuthProvider, enabl
|
||||
return services.CheckLockout(services.IsLocalLoginEnabled(instanceID), n-1)
|
||||
}
|
||||
|
||||
// ackAuthProviderNotice godoc
|
||||
//
|
||||
// @Summary Acknowledge a provider migration notice
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} AcknowledgedResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id}/ack-notice [post]
|
||||
func ackAuthProviderNotice(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
providerID := c.Param("id")
|
||||
@@ -191,10 +265,21 @@ func ackAuthProviderNotice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "auth_provider.ack_notice", actorFromCtx(c), "", "", existing.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"acknowledged": true})
|
||||
c.JSON(http.StatusOK, AcknowledgedResponse{Acknowledged: true})
|
||||
}
|
||||
|
||||
// testAuthProvider proves the configuration is reachable. It signs nobody in.
|
||||
// testAuthProvider godoc
|
||||
//
|
||||
// @Summary Test an SSO provider's reachability
|
||||
// @Description Proves the configuration is reachable. It signs nobody in.
|
||||
// @Tags auth-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "Provider ID"
|
||||
// @Success 200 {object} TestProviderResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /auth/providers/{id}/test [post]
|
||||
func testAuthProvider(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
p, err := services.GetAuthProvider(instanceID, c.Param("id"))
|
||||
@@ -206,15 +291,15 @@ func testAuthProvider(c *gin.Context) {
|
||||
// GitHub has no discovery document. The only meaningful check without
|
||||
// a user token is that credentials are present.
|
||||
if p.ClientID == "" || p.ClientSecretEnc == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "message": "client ID and secret are required"})
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: "client ID and secret are required"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "credentials are configured"})
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "credentials are configured"})
|
||||
return
|
||||
}
|
||||
if _, err := oidc.NewProvider(c.Request.Context(), p.Issuer); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "message": err.Error()})
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "discovery document fetched"})
|
||||
c.JSON(http.StatusOK, TestProviderResponse{OK: true, Message: "discovery document fetched"})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@ func registerChannelRoutes(g *gin.RouterGroup) {
|
||||
g.POST("/channels/:id/test", testChannel)
|
||||
}
|
||||
|
||||
// listChannels godoc
|
||||
//
|
||||
// @Summary List notification channels
|
||||
// @Tags channels
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.NotificationChannel
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels [get]
|
||||
func listChannels(c *gin.Context) {
|
||||
channels, err := services.ListChannels(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -27,6 +37,20 @@ func listChannels(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, channels)
|
||||
}
|
||||
|
||||
// createChannel godoc
|
||||
//
|
||||
// @Summary Create a notification channel
|
||||
// @Tags channels
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.NotificationChannel true "Channel to create"
|
||||
// @Success 201 {object} models.NotificationChannel
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels [post]
|
||||
func createChannel(c *gin.Context) {
|
||||
var ch models.NotificationChannel
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
@@ -48,6 +72,20 @@ func createChannel(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// updateChannel godoc
|
||||
//
|
||||
// @Summary Update a notification channel
|
||||
// @Tags channels
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Param body body object{name=string,type=string,config=map[string]string,enabled=bool} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id} [put]
|
||||
func updateChannel(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
@@ -83,6 +121,16 @@ func updateChannel(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteChannel godoc
|
||||
//
|
||||
// @Summary Delete a notification channel
|
||||
// @Tags channels
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Success 204
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id} [delete]
|
||||
func deleteChannel(c *gin.Context) {
|
||||
if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -91,10 +139,21 @@ func deleteChannel(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// testChannel godoc
|
||||
//
|
||||
// @Summary Send a test notification
|
||||
// @Tags channels
|
||||
// @Produce json
|
||||
// @Param id path string true "Channel ID"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /channels/{id}/test [post]
|
||||
func testChannel(c *gin.Context) {
|
||||
if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "sent"})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,22 @@ import (
|
||||
"github.com/wwt/guac"
|
||||
)
|
||||
|
||||
// consoleConnect godoc
|
||||
//
|
||||
// @Summary Open a browser console session
|
||||
// @Description Mints a one-time session token for the /console/tunnel websocket. Requires a live agent — answers 409 agent_offline otherwise.
|
||||
// @Tags console
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{server_id=string,protocol=string,key_id=string,rdp_username=string,rdp_password=string,ssh_username=string} true "Session parameters"
|
||||
// @Success 200 {object} ConsoleConnectResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /console/connect [post]
|
||||
func consoleConnect(c *gin.Context) {
|
||||
var body struct {
|
||||
ServerID string `json:"server_id" binding:"required"`
|
||||
@@ -73,10 +89,10 @@ func consoleConnect(c *gin.Context) {
|
||||
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
|
||||
"console session opened ("+body.Protocol+", agent-relayed)")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session_id": sess.SessionID,
|
||||
"token": token,
|
||||
"ws_path": "/api/console/tunnel",
|
||||
c.JSON(http.StatusOK, ConsoleConnectResponse{
|
||||
SessionID: sess.SessionID,
|
||||
Token: token,
|
||||
WSPath: "/api/console/tunnel",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,6 +116,21 @@ func queryIntDefault(r *http.Request, key string, def int) int {
|
||||
//
|
||||
// Lines are prefixed with the session ID so one attempt can be followed across
|
||||
// pods, and the pod's own hostname so it is obvious which one served it.
|
||||
// consoleTunnel godoc
|
||||
//
|
||||
// @Summary Console websocket tunnel
|
||||
// @Description Upgrades the browser's connection to a websocket and joins it to guacd, relayed through the agent. Consumes the one-time session token from /console/connect.
|
||||
// @Tags console
|
||||
// @Param token query string true "One-time session token"
|
||||
// @Success 101
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /console/tunnel [get]
|
||||
func consoleTunnel(c *gin.Context) {
|
||||
host, _ := os.Hostname()
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package docs holds the generated OpenAPI document and the vendored Scalar
|
||||
// bundle that renders it.
|
||||
//
|
||||
// openapi.json is generated by `swag init` and committed rather than built into
|
||||
// the image: server/Dockerfile produces a scratch runtime from a Go build
|
||||
// stage, and adding codegen there means putting the toolchain in the image.
|
||||
// server-deploy.yml regenerates and diffs it, so an annotation edited without
|
||||
// regenerating fails the build.
|
||||
//
|
||||
// scalar.standalone.js is vendored from
|
||||
// https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest/dist/browser/standalone.js
|
||||
// and refreshed by hand. Fetched at build time it would break an air-gapped
|
||||
// install; fetched at page load it would break an air-gapped install more
|
||||
// visibly.
|
||||
package docs
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed openapi.json
|
||||
var OpenAPI []byte
|
||||
|
||||
//go:embed scalar.standalone.js
|
||||
var ScalarJS []byte
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+344
-39
@@ -14,10 +14,17 @@ import (
|
||||
)
|
||||
|
||||
func actorFromCtx(c *gin.Context) string {
|
||||
if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" {
|
||||
return sess.Email
|
||||
sess := auth.GetSessionFromContext(c)
|
||||
if sess == nil || sess.Email == "" {
|
||||
return "admin"
|
||||
}
|
||||
return "admin"
|
||||
// The actor stays the human, because a token acts on their behalf and the
|
||||
// log has to name somebody. The credential is appended so a person clicking
|
||||
// and their CI job are told apart.
|
||||
if sess.TokenID != "" {
|
||||
return fmt.Sprintf("%s (via token:%s)", sess.Email, sess.TokenName)
|
||||
}
|
||||
return sess.Email
|
||||
}
|
||||
|
||||
func RegisterRoutes(r *gin.Engine) {
|
||||
@@ -42,6 +49,11 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
// Scope enforcement sits between authentication and the licence gate, and
|
||||
// no-ops for cookie sessions. It is mounted here rather than per route so
|
||||
// a route added later is covered by where it lives, not by memory.
|
||||
apiGroup.Use(RequireScopes())
|
||||
apiGroup.Use(RateLimitTokens())
|
||||
// Deny by default: every non-GET route under /api is gated unless it is on
|
||||
// the exemption list in licence.go. A route added later is covered because
|
||||
// of where it is mounted, not because someone remembered.
|
||||
@@ -68,6 +80,15 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
apiGroup.GET("/audit", listAuditEvents)
|
||||
|
||||
apiGroup.GET("/tokens", listTokens)
|
||||
apiGroup.GET("/tokens/scopes", listTokenScopes)
|
||||
apiGroup.POST("/tokens", createToken)
|
||||
apiGroup.DELETE("/tokens/:id", revokeToken)
|
||||
|
||||
apiGroup.GET("/openapi.json", getOpenAPI)
|
||||
apiGroup.GET("/docs", getAPIDocs)
|
||||
apiGroup.GET("/docs/scalar.js", getScalarJS)
|
||||
|
||||
settings := apiGroup.Group("/settings")
|
||||
settings.Use(auth.RequireRole("owner", "admin"))
|
||||
{
|
||||
@@ -144,6 +165,19 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
}
|
||||
}
|
||||
|
||||
// listServers godoc
|
||||
//
|
||||
// @Summary List servers
|
||||
// @Description Returns every server in the instance, optionally filtered by tag (repeatable, key:value).
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param tag query []string false "Filter by tag as key:value, repeatable"
|
||||
// @Success 200 {array} models.Server
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers [get]
|
||||
func listServers(c *gin.Context) {
|
||||
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
|
||||
if err != nil {
|
||||
@@ -158,6 +192,17 @@ func listServers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, servers)
|
||||
}
|
||||
|
||||
// listKnownTags godoc
|
||||
//
|
||||
// @Summary List known tags
|
||||
// @Description Returns every tag key currently used by any server, with the values seen for each.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string][]string
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/tags [get]
|
||||
func listKnownTags(c *gin.Context) {
|
||||
tags, err := services.KnownTags(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -167,6 +212,22 @@ func listKnownTags(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// putServerTags godoc
|
||||
//
|
||||
// @Summary Replace a server's tags
|
||||
// @Description Replaces the whole tag map for a server. Last write wins.
|
||||
// @Tags servers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param body body object{tags=map[string]string} true "New tag map"
|
||||
// @Success 200 {object} TagsResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/tags [put]
|
||||
func putServerTags(c *gin.Context) {
|
||||
var body struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
@@ -196,9 +257,21 @@ func putServerTags(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "server.tags_updated", actorFromCtx(c), serverID, "",
|
||||
fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags))
|
||||
c.JSON(http.StatusOK, gin.H{"tags": body.Tags})
|
||||
c.JSON(http.StatusOK, TagsResponse{Tags: body.Tags})
|
||||
}
|
||||
|
||||
// createServer godoc
|
||||
//
|
||||
// @Summary Add a server
|
||||
// @Description Creates a server record and a single-use pre-registration token (TTL 1 hour).
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 201 {object} CreateServerResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers [post]
|
||||
func createServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -208,13 +281,26 @@ func createServer(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"server": s,
|
||||
"token": token,
|
||||
"server_id": s.ServerID,
|
||||
c.JSON(http.StatusCreated, CreateServerResponse{
|
||||
Server: s,
|
||||
Token: token,
|
||||
ServerID: s.ServerID,
|
||||
})
|
||||
}
|
||||
|
||||
// newServer godoc
|
||||
//
|
||||
// @Summary Add a server (install page)
|
||||
// @Description Identical to POST /servers; also reachable by GET for the install page. Mints a new pre-registration token.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} NewServerResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/new [get]
|
||||
// @Router /servers/new [post]
|
||||
func newServer(c *gin.Context) {
|
||||
s, token, err := services.CreateServer(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -238,14 +324,26 @@ func newServer(c *gin.Context) {
|
||||
host, s.ServerID, token,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"server_id": s.ServerID,
|
||||
"pre_reg_token": token,
|
||||
"install_command": installCmd,
|
||||
"install_command_ps": installCmdPS,
|
||||
c.JSON(http.StatusOK, NewServerResponse{
|
||||
ServerID: s.ServerID,
|
||||
PreRegToken: token,
|
||||
InstallCommand: installCmd,
|
||||
InstallCommandPS: installCmdPS,
|
||||
})
|
||||
}
|
||||
|
||||
// getServer godoc
|
||||
//
|
||||
// @Summary Get a server
|
||||
// @Description Returns a server together with its resolved key assignments.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} ServerDetailResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id} [get]
|
||||
func getServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
@@ -256,16 +354,23 @@ func getServer(c *gin.Context) {
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id)
|
||||
|
||||
type serverResponse struct {
|
||||
*models.Server
|
||||
Keys interface{} `json:"keys"`
|
||||
}
|
||||
c.JSON(http.StatusOK, serverResponse{
|
||||
c.JSON(http.StatusOK, ServerDetailResponse{
|
||||
Server: s,
|
||||
Keys: assignments,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteServer godoc
|
||||
//
|
||||
// @Summary Delete a server
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id} [delete]
|
||||
func deleteServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, _ := services.GetServer(auth.InstanceID(c), id)
|
||||
@@ -278,9 +383,24 @@ func deleteServer(c *gin.Context) {
|
||||
hostname = s.Hostname
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// generateKey godoc
|
||||
//
|
||||
// @Summary Generate a key on a server
|
||||
// @Description Dispatches an agent command that generates a keypair on the target server and reports it back.
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param body body object{label=string,key_type=string,key_size=int,passphrase=string,comment=string} false "Key generation parameters"
|
||||
// @Success 202 {object} GenerateKeyResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/generate-key [post]
|
||||
func generateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -315,13 +435,23 @@ func generateKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "key generation command sent to agent",
|
||||
"command_id": cmdID,
|
||||
"server_id": s.ServerID,
|
||||
c.JSON(http.StatusAccepted, GenerateKeyResponse{
|
||||
Message: "key generation command sent to agent",
|
||||
CommandID: cmdID,
|
||||
ServerID: s.ServerID,
|
||||
})
|
||||
}
|
||||
|
||||
// listKeys godoc
|
||||
//
|
||||
// @Summary List keys
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Key
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys [get]
|
||||
func listKeys(c *gin.Context) {
|
||||
keys, err := services.ListKeys(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -331,6 +461,19 @@ func listKeys(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// createKey godoc
|
||||
//
|
||||
// @Summary Upload a key
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{label=string,public_key=string,private_key=string,passphrase=string} true "Key material"
|
||||
// @Success 201 {object} models.Key
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys [post]
|
||||
func createKey(c *gin.Context) {
|
||||
var body struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
@@ -352,6 +495,18 @@ func createKey(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, key)
|
||||
}
|
||||
|
||||
// getPrivateKey godoc
|
||||
//
|
||||
// @Summary Get a key's private material
|
||||
// @Description Returns the decrypted private key. Reading is a keys:read action even though the material is sensitive.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} PrivateKeyResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/private-key [get]
|
||||
func getPrivateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id)
|
||||
@@ -359,9 +514,21 @@ func getPrivateKey(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"private_key": plaintext})
|
||||
c.JSON(http.StatusOK, PrivateKeyResponse{PrivateKey: plaintext})
|
||||
}
|
||||
|
||||
// getKey godoc
|
||||
//
|
||||
// @Summary Get a key
|
||||
// @Description Returns a key together with the servers it is assigned to.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} KeyDetailResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id} [get]
|
||||
func getKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key, err := services.GetKey(auth.InstanceID(c), id)
|
||||
@@ -372,16 +539,23 @@ func getKey(c *gin.Context) {
|
||||
|
||||
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
|
||||
|
||||
type keyResponse struct {
|
||||
*models.Key
|
||||
Assignments any `json:"assignments"`
|
||||
}
|
||||
c.JSON(http.StatusOK, keyResponse{
|
||||
c.JSON(http.StatusOK, KeyDetailResponse{
|
||||
Key: key,
|
||||
Assignments: assignments,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteKey godoc
|
||||
//
|
||||
// @Summary Delete a key
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id} [delete]
|
||||
func deleteKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
k, _ := services.GetKey(auth.InstanceID(c), id)
|
||||
@@ -394,9 +568,23 @@ func deleteKey(c *gin.Context) {
|
||||
label = k.Label
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// assignKey godoc
|
||||
//
|
||||
// @Summary Assign a key to a server
|
||||
// @Tags keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Param body body object{server_id=string} true "Target server"
|
||||
// @Success 201 {object} models.Assignment
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/assign [post]
|
||||
func assignKey(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
var body struct {
|
||||
@@ -416,6 +604,19 @@ func assignKey(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, a)
|
||||
}
|
||||
|
||||
// revokeAssignment godoc
|
||||
//
|
||||
// @Summary Revoke a key assignment
|
||||
// @Description Soft revocation: sets revoked_at rather than deleting, preserving audit history.
|
||||
// @Tags keys
|
||||
// @Produce json
|
||||
// @Param id path string true "Key ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {object} RevokedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /keys/{id}/assign/{serverId} [delete]
|
||||
func revokeAssignment(c *gin.Context) {
|
||||
keyID := c.Param("id")
|
||||
serverID := c.Param("serverId")
|
||||
@@ -425,18 +626,42 @@ func revokeAssignment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
|
||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||
c.JSON(http.StatusOK, RevokedResponse{Revoked: true})
|
||||
}
|
||||
|
||||
// getLatestAgentVersion godoc
|
||||
//
|
||||
// @Summary Get the latest agent version
|
||||
// @Description Reads the latest agent/v* tag from the Gitea release API.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Success 200 {object} AgentVersionResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /agent/latest-version [get]
|
||||
func getLatestAgentVersion(c *gin.Context) {
|
||||
version, err := services.GetLatestAgentVersion()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"version": version})
|
||||
c.JSON(http.StatusOK, AgentVersionResponse{Version: version})
|
||||
}
|
||||
|
||||
// updateAgent godoc
|
||||
//
|
||||
// @Summary Update a server's agent
|
||||
// @Description Dispatches UpdateAgentCmd to the agent, telling it to download and replace itself.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} UpdateAgentResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/update-agent [post]
|
||||
func updateAgent(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
@@ -451,12 +676,25 @@ func updateAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "update command sent to agent",
|
||||
"version": version,
|
||||
c.JSON(http.StatusAccepted, UpdateAgentResponse{
|
||||
Message: "update command sent to agent",
|
||||
Version: version,
|
||||
})
|
||||
}
|
||||
|
||||
// applyUpdates godoc
|
||||
//
|
||||
// @Summary Apply pending OS updates on a server
|
||||
// @Description Dispatches ApplyUpdatesCmd. Exempt from the licence gate: security patching is never paywalled.
|
||||
// @Tags servers
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} MessageResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/apply-updates [post]
|
||||
func applyUpdates(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
s, err := services.GetServer(auth.InstanceID(c), id)
|
||||
@@ -470,9 +708,17 @@ func applyUpdates(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
|
||||
c.JSON(http.StatusAccepted, MessageResponse{Message: "apply updates command sent to agent"})
|
||||
}
|
||||
|
||||
// handleUpdateScript godoc
|
||||
//
|
||||
// @Summary Agent update script (Linux)
|
||||
// @Description Dynamically generated shell script that downloads and installs the latest agent.
|
||||
// @Tags install
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string "shell script"
|
||||
// @Router /update [get]
|
||||
func handleUpdateScript(c *gin.Context) {
|
||||
giteaHost := "gitea.hostxtra.co.uk"
|
||||
|
||||
@@ -526,6 +772,21 @@ echo "vantage-agent updated to ${VERSION} and restarted."
|
||||
c.String(http.StatusOK, script)
|
||||
}
|
||||
|
||||
// listAuditEvents godoc
|
||||
//
|
||||
// @Summary List audit events
|
||||
// @Description Every mutating API path writes an audit event. Paginated with a total, since a short page is not proof of the end of the log.
|
||||
// @Tags audit
|
||||
// @Produce json
|
||||
// @Param q query string false "Free-text search"
|
||||
// @Param category query string false "Filter by category"
|
||||
// @Param limit query int false "Max events to return"
|
||||
// @Param skip query int false "Events to skip"
|
||||
// @Success 200 {object} AuditEventsResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /audit [get]
|
||||
func listAuditEvents(c *gin.Context) {
|
||||
f := services.AuditFilter{
|
||||
Search: c.Query("q"),
|
||||
@@ -549,9 +810,19 @@ func listAuditEvents(c *gin.Context) {
|
||||
}
|
||||
// An object rather than a bare array: a page is meaningless without the
|
||||
// total it came from, and a short page is not proof of the end of the log.
|
||||
c.JSON(http.StatusOK, gin.H{"events": events, "total": total})
|
||||
c.JSON(http.StatusOK, AuditEventsResponse{Events: events, Total: total})
|
||||
}
|
||||
|
||||
// getSettings godoc
|
||||
//
|
||||
// @Summary Get instance settings
|
||||
// @Tags settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.Settings
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings [get]
|
||||
func getSettings(c *gin.Context) {
|
||||
s, err := services.GetSettings(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -561,17 +832,37 @@ func getSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// saveSettings godoc
|
||||
//
|
||||
// @Summary Save instance settings
|
||||
// @Description Owner and admin only. Refuses a change that would leave neither local login nor an enabled auth provider.
|
||||
// @Tags settings
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{alerts=models.AlertSettings,workflow_log_retention_days=int,local_login_enabled=bool,api_token_max_days=int} true "Settings to save"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings [put]
|
||||
func saveSettings(c *gin.Context) {
|
||||
var body struct {
|
||||
Alerts models.AlertSettings `json:"alerts"`
|
||||
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
|
||||
LocalLoginEnabled *bool `json:"local_login_enabled"`
|
||||
APITokenMaxDays *int `json:"api_token_max_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled); err != nil {
|
||||
if body.APITokenMaxDays != nil && *body.APITokenMaxDays < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil {
|
||||
if errors.Is(err, services.ErrLockout) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
|
||||
return
|
||||
@@ -580,9 +871,23 @@ func saveSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
if body.APITokenMaxDays != nil {
|
||||
services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays))
|
||||
}
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// handleInstallScript godoc
|
||||
//
|
||||
// @Summary Agent install script (Linux)
|
||||
// @Description Dynamically generated shell script that downloads, verifies and installs the agent, seeded with a pre-registration token.
|
||||
// @Tags install
|
||||
// @Produce plain
|
||||
// @Param server_id query string true "Server ID"
|
||||
// @Param token query string true "Pre-registration token"
|
||||
// @Success 200 {string} string "shell script"
|
||||
// @Router /install [get]
|
||||
func handleInstallScript(c *gin.Context) {
|
||||
serverID := c.Query("server_id")
|
||||
token := c.Query("token")
|
||||
|
||||
@@ -10,6 +10,16 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// listInstanceUsers godoc
|
||||
//
|
||||
// @Summary List instance members
|
||||
// @Tags instance-users
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.User
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users [get]
|
||||
func listInstanceUsers(c *gin.Context) {
|
||||
users, err := services.ListUsers(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -23,6 +33,20 @@ func actorMayGrantOwner(c *gin.Context) bool {
|
||||
return auth.Role(c) == models.RoleOwner
|
||||
}
|
||||
|
||||
// createInstanceUser godoc
|
||||
//
|
||||
// @Summary Create an instance member
|
||||
// @Description Only an owner can create another owner.
|
||||
// @Tags instance-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{email=string,password=string,role=string} true "New member"
|
||||
// @Success 201 {object} models.User
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users [post]
|
||||
func createInstanceUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
@@ -52,6 +76,24 @@ func createInstanceUser(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, u)
|
||||
}
|
||||
|
||||
// updateInstanceUserRole godoc
|
||||
//
|
||||
// @Summary Change an instance member's role
|
||||
// @Description A caller cannot change their own role. Only an owner can change owner roles.
|
||||
// @Tags instance-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Param body body object{role=string} true "New role"
|
||||
// @Success 200 {object} OKResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users/{id}/role [put]
|
||||
func updateInstanceUserRole(c *gin.Context) {
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
@@ -84,9 +126,24 @@ func updateInstanceUserRole(c *gin.Context) {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||||
}
|
||||
|
||||
// deleteInstanceUser godoc
|
||||
//
|
||||
// @Summary Remove an instance member
|
||||
// @Description A caller cannot remove their own account. Only an owner can remove another owner.
|
||||
// @Tags instance-users
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /instance/users/{id} [delete]
|
||||
func deleteInstanceUser(c *gin.Context) {
|
||||
instanceID, targetID := auth.InstanceID(c), c.Param("id")
|
||||
if targetID == auth.UserID(c) {
|
||||
@@ -107,7 +164,7 @@ func deleteInstanceUser(c *gin.Context) {
|
||||
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
func orgUserErrStatus(err error) int {
|
||||
|
||||
@@ -116,6 +116,15 @@ type licenceUsageResponse struct {
|
||||
Channels int `json:"channels"`
|
||||
}
|
||||
|
||||
// getLicence godoc
|
||||
//
|
||||
// @Summary Get this instance's licence state
|
||||
// @Tags licence
|
||||
// @Produce json
|
||||
// @Success 200 {object} licenceResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /license [get]
|
||||
func getLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
st := services.GetLicenseState(instanceID)
|
||||
@@ -169,6 +178,21 @@ func licencePostAllowed(instanceID string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// postLicence godoc
|
||||
//
|
||||
// @Summary Set this instance's licence
|
||||
// @Description Self-hosted only; a cloud instance's licence is injected by admin and this endpoint answers 409 cloud_managed. Exempt from the licence gate, since pasting a valid licence is the way out of degraded mode. Rate limited to 10 attempts per instance per hour.
|
||||
// @Tags licence
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{blob=string} true "Licence key blob"
|
||||
// @Success 200 {object} LicencePostResponse
|
||||
// @Failure 400 {object} LicenceErrorResponse
|
||||
// @Failure 409 {object} LicenceErrorResponse
|
||||
// @Failure 429 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /license [post]
|
||||
func postLicence(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
|
||||
@@ -210,7 +234,7 @@ func postLicence(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
|
||||
"licence accepted (tier "+st.Tier+")")
|
||||
c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
|
||||
c.JSON(http.StatusOK, LicencePostResponse{State: st.Status, Tier: st.Tier, ExpiresAt: st.ExpiresAt})
|
||||
}
|
||||
|
||||
// licenceRejectionMessage turns a machine reason into something a person can act
|
||||
@@ -238,11 +262,11 @@ func limitStatus(c *gin.Context, err error) bool {
|
||||
if !errors.As(err, &le) {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "limit_exceeded",
|
||||
"limit": le.Limit,
|
||||
"current": le.Current,
|
||||
"max": le.Max,
|
||||
c.JSON(http.StatusForbidden, LimitExceededResponse{
|
||||
Error: "limit_exceeded",
|
||||
Limit: le.Limit,
|
||||
Current: le.Current,
|
||||
Max: le.Max,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
}
|
||||
|
||||
// listMonitors godoc
|
||||
//
|
||||
// @Summary List monitors
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Monitor
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors [get]
|
||||
func listMonitors(c *gin.Context) {
|
||||
monitors, err := services.ListMonitors(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -30,6 +40,20 @@ func listMonitors(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, monitors)
|
||||
}
|
||||
|
||||
// createMonitor godoc
|
||||
//
|
||||
// @Summary Create a monitor
|
||||
// @Tags monitors
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.Monitor true "Monitor to create"
|
||||
// @Success 201 {object} models.Monitor
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors [post]
|
||||
func createMonitor(c *gin.Context) {
|
||||
var m models.Monitor
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
@@ -55,6 +79,18 @@ func createMonitor(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// getMonitor godoc
|
||||
//
|
||||
// @Summary Get a monitor
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {object} models.Monitor
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [get]
|
||||
func getMonitor(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -68,6 +104,20 @@ func getMonitor(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
// updateMonitor godoc
|
||||
//
|
||||
// @Summary Update a monitor
|
||||
// @Tags monitors
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param body body object{name=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [put]
|
||||
func updateMonitor(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
@@ -119,6 +169,16 @@ func updateMonitor(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteMonitor godoc
|
||||
//
|
||||
// @Summary Delete a monitor
|
||||
// @Tags monitors
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 204
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id} [delete]
|
||||
func deleteMonitor(c *gin.Context) {
|
||||
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -127,6 +187,18 @@ func deleteMonitor(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// getMonitorIncidents godoc
|
||||
//
|
||||
// @Summary List a monitor's incidents
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {array} models.Incident
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/incidents [get]
|
||||
func getMonitorIncidents(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -145,6 +217,19 @@ func getMonitorIncidents(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
// getMonitorUptime godoc
|
||||
//
|
||||
// @Summary Get a monitor's uptime rollups
|
||||
// @Description Hourly rollups for the last 30 days.
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Success 200 {array} models.Rollup
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/uptime [get]
|
||||
func getMonitorUptime(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/api/docs"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// scalarPage renders the reference against this instance's own spec, so "Try
|
||||
// it" acts on the reader's API with the reader's session.
|
||||
const scalarPage = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Vantage API</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/api/docs/scalar.js"></script>
|
||||
<script>
|
||||
Scalar.createApiReference('#app', {
|
||||
url: '/api/openapi.json',
|
||||
theme: 'deepSpace',
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// getOpenAPI godoc
|
||||
//
|
||||
// @Summary Get the OpenAPI document
|
||||
// @Description Generated from swaggo annotations at build time and committed; served verbatim.
|
||||
// @Tags docs
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /openapi.json [get]
|
||||
func getOpenAPI(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", docs.OpenAPI)
|
||||
}
|
||||
|
||||
// getScalarJS godoc
|
||||
//
|
||||
// @Summary Get the vendored Scalar bundle
|
||||
// @Description Served locally rather than from a CDN so the reference page works on an air-gapped install.
|
||||
// @Tags docs
|
||||
// @Produce application/javascript
|
||||
// @Success 200 {string} string "javascript bundle"
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /docs/scalar.js [get]
|
||||
func getScalarJS(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/javascript; charset=utf-8", docs.ScalarJS)
|
||||
}
|
||||
|
||||
// getAPIDocs godoc
|
||||
//
|
||||
// @Summary API reference page
|
||||
// @Description Renders the Scalar reference against this instance's own OpenAPI document.
|
||||
// @Tags docs
|
||||
// @Produce html
|
||||
// @Success 200 {string} string "HTML page"
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /docs [get]
|
||||
func getAPIDocs(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarPage))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// tokenRateLimit is per token per minute. It is not the general API
|
||||
// rate-limiting project: it is only enough that a runaway script cannot take an
|
||||
// instance down, and cookie sessions are deliberately untouched.
|
||||
const tokenRateLimit = 600
|
||||
|
||||
// RateLimitTokens counts requests per token in a one-minute fixed window.
|
||||
//
|
||||
// A fixed window rather than a sliding one because the cost of a burst at a
|
||||
// boundary is a script running twice as fast for one second, and a sliding
|
||||
// window is a sorted set per token for that.
|
||||
func RateLimitTokens() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !auth.IsToken(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
rdb := auth.Redis()
|
||||
if rdb == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
window := time.Now().UTC().Unix() / 60
|
||||
key := "vantage:tokenrate:" + auth.TokenID(c) + ":" + strconv.FormatInt(window, 10)
|
||||
|
||||
count, err := rdb.Incr(c.Request.Context(), key).Result()
|
||||
if err != nil {
|
||||
// Redis is already required for sessions, so it being down is a
|
||||
// larger problem than this. Do not turn it into a second outage.
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if count == 1 {
|
||||
rdb.Expire(c.Request.Context(), key, 2*time.Minute)
|
||||
}
|
||||
if count > tokenRateLimit {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate limit exceeded for this API token",
|
||||
"code": "rate_limited",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// routeScopes maps a registered gin route — "<METHOD> <full path pattern>" — to
|
||||
// the scope an API token must hold to reach it.
|
||||
//
|
||||
// It is keyed on the route pattern rather than declared per route with a
|
||||
// decorator, because a route registered without a decorator would be
|
||||
// unguarded. AssertScopeMapComplete refuses to boot if any /api route is
|
||||
// missing here, so the failure lands at deploy rather than as a surprise 403
|
||||
// in production.
|
||||
//
|
||||
// GET is read, everything else is write. The exceptions are written out rather
|
||||
// than derived, because two of them are not obvious: reading a private key is
|
||||
// still reading a key, and reading a container's logs is a write-level action
|
||||
// because container output is arbitrary and cannot be masked.
|
||||
var routeScopes = map[string]string{
|
||||
"GET /api/license": "settings:read",
|
||||
"POST /api/license": "settings:write",
|
||||
|
||||
"GET /api/servers": "servers:read",
|
||||
"GET /api/servers/tags": "servers:read",
|
||||
"POST /api/servers": "servers:write",
|
||||
"GET /api/servers/new": "servers:write",
|
||||
"POST /api/servers/new": "servers:write",
|
||||
"GET /api/servers/:id": "servers:read",
|
||||
"DELETE /api/servers/:id": "servers:write",
|
||||
"POST /api/servers/:id/generate-key": "keys:write",
|
||||
"POST /api/servers/:id/update-agent": "servers:write",
|
||||
"POST /api/servers/:id/apply-updates": "servers:write",
|
||||
"PUT /api/servers/:id/tags": "servers:write",
|
||||
|
||||
"GET /api/agent/latest-version": "servers:read",
|
||||
"GET /api/audit": "settings:read",
|
||||
|
||||
"GET /api/settings": "settings:read",
|
||||
"PUT /api/settings": "settings:write",
|
||||
"POST /api/settings/secrets-token": "settings:write",
|
||||
|
||||
"GET /api/secrets": "secrets:read",
|
||||
"POST /api/secrets": "secrets:write",
|
||||
"GET /api/secrets/:group": "secrets:read",
|
||||
"PUT /api/secrets/:group": "secrets:write",
|
||||
"POST /api/secrets/:group/reveal": "secrets:read",
|
||||
"DELETE /api/secrets/:group": "secrets:write",
|
||||
"DELETE /api/secrets/:group/:key": "secrets:write",
|
||||
|
||||
"GET /api/keys": "keys:read",
|
||||
"POST /api/keys": "keys:write",
|
||||
"GET /api/keys/:id": "keys:read",
|
||||
"GET /api/keys/:id/private-key": "keys:read",
|
||||
"DELETE /api/keys/:id": "keys:write",
|
||||
"POST /api/keys/:id/assign": "keys:write",
|
||||
"DELETE /api/keys/:id/assign/:serverId": "keys:write",
|
||||
|
||||
"POST /api/console/connect": "servers:write",
|
||||
"GET /api/console/tunnel": "servers:write",
|
||||
|
||||
// Workflow, step and run routes, registered by registerWorkflowRoutes.
|
||||
"GET /api/steps": "workflows:read",
|
||||
"POST /api/steps": "workflows:write",
|
||||
"PUT /api/steps/:id": "workflows:write",
|
||||
"DELETE /api/steps/:id": "workflows:write",
|
||||
"GET /api/steps/:id/export": "workflows:read",
|
||||
"POST /api/steps/import": "workflows:write",
|
||||
"POST /api/steps/seed-defaults": "workflows:write",
|
||||
"GET /api/steps/usage": "workflows:read",
|
||||
"POST /api/steps/parse": "workflows:write",
|
||||
|
||||
"GET /api/workflows": "workflows:read",
|
||||
"POST /api/workflows": "workflows:write",
|
||||
"GET /api/workflows/:id": "workflows:read",
|
||||
"PUT /api/workflows/:id": "workflows:write",
|
||||
"DELETE /api/workflows/:id": "workflows:write",
|
||||
"POST /api/workflows/:id/run": "workflows:write",
|
||||
"GET /api/workflows/:id/runs": "workflows:read",
|
||||
"PUT /api/workflows/:id/schedule": "workflows:write",
|
||||
"GET /api/workflows/:id/schedule/preview": "workflows:read",
|
||||
|
||||
"GET /api/runs/:runId": "workflows:read",
|
||||
"POST /api/runs/:runId/cancel": "workflows:write",
|
||||
"GET /api/runs/:runId/servers/:serverId/logs": "workflows:read",
|
||||
"GET /api/runs/:runId/servers/:serverId/logs/stream": "workflows:read",
|
||||
|
||||
// Monitor and incident routes, registered by registerMonitorRoutes.
|
||||
"GET /api/monitors": "monitors:read",
|
||||
"POST /api/monitors": "monitors:write",
|
||||
"GET /api/monitors/:id": "monitors:read",
|
||||
"PUT /api/monitors/:id": "monitors:write",
|
||||
"DELETE /api/monitors/:id": "monitors:write",
|
||||
"GET /api/monitors/:id/incidents": "monitors:read",
|
||||
"GET /api/monitors/:id/uptime": "monitors:read",
|
||||
|
||||
// Channel routes, registered by registerChannelRoutes. Channels exist to
|
||||
// serve alerts, so they share the monitors scope rather than getting their
|
||||
// own resource.
|
||||
"GET /api/channels": "monitors:read",
|
||||
"POST /api/channels": "monitors:write",
|
||||
"PUT /api/channels/:id": "monitors:write",
|
||||
"DELETE /api/channels/:id": "monitors:write",
|
||||
"POST /api/channels/:id/test": "monitors:write",
|
||||
|
||||
// Instance user management and SSO configuration live on the /settings
|
||||
// page in web/ (the Access group), so both share the settings scope.
|
||||
"GET /api/instance/users": "settings:read",
|
||||
"POST /api/instance/users": "settings:write",
|
||||
"PUT /api/instance/users/:id/role": "settings:write",
|
||||
"DELETE /api/instance/users/:id": "settings:write",
|
||||
|
||||
"GET /api/auth/providers": "settings:read",
|
||||
"POST /api/auth/providers": "settings:write",
|
||||
"PUT /api/auth/providers/:id": "settings:write",
|
||||
"DELETE /api/auth/providers/:id": "settings:write",
|
||||
"POST /api/auth/providers/:id/test": "settings:write",
|
||||
"POST /api/auth/providers/:id/ack-notice": "settings:write",
|
||||
"GET /api/auth/presets": "settings:read",
|
||||
|
||||
"GET /api/vulnerabilities": "vulns:read",
|
||||
"GET /api/vulnerabilities/summary": "vulns:read",
|
||||
"POST /api/vulnerabilities/rescan": "vulns:write",
|
||||
"POST /api/vulnerabilities/:id/accept": "vulns:write",
|
||||
"DELETE /api/vulnerabilities/:id/accept": "vulns:write",
|
||||
"GET /api/servers/:id/vulnerabilities": "vulns:read",
|
||||
"GET /api/servers/:id/packages": "vulns:read",
|
||||
"GET /api/packages/search": "vulns:read",
|
||||
"GET /api/vuln-rules": "vulns:read",
|
||||
"POST /api/vuln-rules": "vulns:write",
|
||||
"PUT /api/vuln-rules/:id": "vulns:write",
|
||||
"DELETE /api/vuln-rules/:id": "vulns:write",
|
||||
|
||||
"GET /api/workloads": "workloads:read",
|
||||
"GET /api/servers/:id/workloads": "workloads:read",
|
||||
"POST /api/servers/:id/workloads/refresh": "workloads:read",
|
||||
"POST /api/servers/:id/workloads/:wid/action": "workloads:write",
|
||||
"GET /api/servers/:id/workloads/:wid/logs": "workloads:write",
|
||||
|
||||
"GET /api/tokens": "settings:read",
|
||||
"GET /api/tokens/scopes": "settings:read",
|
||||
"POST /api/tokens": "settings:write",
|
||||
"DELETE /api/tokens/:id": "settings:write",
|
||||
|
||||
// The generated OpenAPI document and its Scalar reference page. Read-only,
|
||||
// so they share the settings:read scope with the rest of the docs a token
|
||||
// can already see about its own instance.
|
||||
"GET /api/openapi.json": "settings:read",
|
||||
"GET /api/docs": "settings:read",
|
||||
"GET /api/docs/scalar.js": "settings:read",
|
||||
}
|
||||
|
||||
// RequireScopes enforces routeScopes for token-authenticated requests and does
|
||||
// nothing at all for cookie sessions, whose authority is their role.
|
||||
func RequireScopes() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !auth.IsToken(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
key := c.Request.Method + " " + c.FullPath()
|
||||
required, ok := routeScopes[key]
|
||||
if !ok {
|
||||
// Fail closed. An unmapped route reached by a token is a route
|
||||
// nobody decided the authority for.
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "this endpoint is not available to API tokens",
|
||||
"code": "scope_unmapped",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !services.ScopeSatisfied(auth.Scopes(c), required) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": fmt.Sprintf("token is missing the %q scope", required),
|
||||
"code": "scope_missing",
|
||||
"required_scope": required,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AssertScopeMapComplete fails boot when a registered /api route has no scope.
|
||||
//
|
||||
// Without it, adding a route silently makes it unreachable by every token, and
|
||||
// the report arrives as a customer asking why their script gets 403.
|
||||
func AssertScopeMapComplete(r *gin.Engine) error {
|
||||
var missing []string
|
||||
for _, route := range r.Routes() {
|
||||
if !strings.HasPrefix(route.Path, "/api/") {
|
||||
continue
|
||||
}
|
||||
// The ESO endpoint keeps its own bearer scheme and is deliberately
|
||||
// outside the token vocabulary.
|
||||
if route.Path == "/api/secrets/:group/values" {
|
||||
continue
|
||||
}
|
||||
if _, ok := routeScopes[route.Method+" "+route.Path]; !ok {
|
||||
missing = append(missing, route.Method+" "+route.Path)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
return fmt.Errorf("routes missing from the API token scope map: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -37,6 +37,19 @@ func secretsReadAuth() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// esoGetGroup godoc
|
||||
//
|
||||
// @Summary Read a secret group's values (ESO)
|
||||
// @Description Consumed by Kubernetes External Secrets Operator. Authenticated with a bearer token whose SHA-256 hash is stored in settings — a different credential from an API token, never substitutable for one.
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security esoAuth
|
||||
// @Router /secrets/{group}/values [get]
|
||||
func esoGetGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
|
||||
@@ -58,6 +71,16 @@ func esoGetGroup(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, values)
|
||||
}
|
||||
|
||||
// listSecretGroups godoc
|
||||
//
|
||||
// @Summary List secret groups
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.GroupSummary
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets [get]
|
||||
func listSecretGroups(c *gin.Context) {
|
||||
groups, err := services.ListSecretGroups(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -67,6 +90,20 @@ func listSecretGroups(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, groups)
|
||||
}
|
||||
|
||||
// createSecretGroup godoc
|
||||
//
|
||||
// @Summary Create a secret group
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{group=string,values=map[string]string} true "Group and its initial key/value pairs"
|
||||
// @Success 201 {object} GroupResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets [post]
|
||||
func createSecretGroup(c *gin.Context) {
|
||||
var body struct {
|
||||
Group string `json:"group" binding:"required"`
|
||||
@@ -98,9 +135,22 @@ func createSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
|
||||
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
|
||||
c.JSON(http.StatusCreated, GroupResponse{Group: body.Group})
|
||||
}
|
||||
|
||||
// getSecretGroup godoc
|
||||
//
|
||||
// @Summary Get a secret group's keys
|
||||
// @Description Returns the group's key metadata, not decrypted values. See POST /secrets/{group}/reveal for a value.
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} SecretGroupResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [get]
|
||||
func getSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
secrets, err := services.GetSecretGroup(auth.InstanceID(c), group)
|
||||
@@ -112,9 +162,24 @@ func getSecretGroup(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
|
||||
c.JSON(http.StatusOK, SecretGroupResponse{Group: group, Secrets: secrets})
|
||||
}
|
||||
|
||||
// putSecretGroup godoc
|
||||
//
|
||||
// @Summary Replace a secret group's keys
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param body body map[string]string true "Key/value pairs"
|
||||
// @Success 200 {object} SavedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} LimitExceededResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [put]
|
||||
func putSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if !validName(group) {
|
||||
@@ -144,9 +209,23 @@ func putSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
|
||||
c.JSON(http.StatusOK, gin.H{"saved": true})
|
||||
c.JSON(http.StatusOK, SavedResponse{Saved: true})
|
||||
}
|
||||
|
||||
// revealSecret godoc
|
||||
//
|
||||
// @Summary Reveal a secret value
|
||||
// @Tags secrets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param body body object{key=string} true "Key to reveal"
|
||||
// @Success 200 {object} RevealSecretResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group}/reveal [post]
|
||||
func revealSecret(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
var body struct {
|
||||
@@ -162,9 +241,21 @@ func revealSecret(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
|
||||
c.JSON(http.StatusOK, gin.H{"value": value})
|
||||
c.JSON(http.StatusOK, RevealSecretResponse{Value: value})
|
||||
}
|
||||
|
||||
// deleteSecretKey godoc
|
||||
//
|
||||
// @Summary Delete a key from a secret group
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Param key path string true "Key name"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group}/{key} [delete]
|
||||
func deleteSecretKey(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
key := c.Param("key")
|
||||
@@ -173,9 +264,20 @@ func deleteSecretKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// deleteSecretGroup godoc
|
||||
//
|
||||
// @Summary Delete a secret group
|
||||
// @Tags secrets
|
||||
// @Produce json
|
||||
// @Param group path string true "Secret group name"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /secrets/{group} [delete]
|
||||
func deleteSecretGroup(c *gin.Context) {
|
||||
group := c.Param("group")
|
||||
if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil {
|
||||
@@ -183,9 +285,19 @@ func deleteSecretGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// rotateSecretsToken godoc
|
||||
//
|
||||
// @Summary Rotate the ESO read token
|
||||
// @Tags settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} SecretsTokenResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /settings/secrets-token [post]
|
||||
func rotateSecretsToken(c *gin.Context) {
|
||||
token, err := services.RotateSecretsReadToken(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -193,5 +305,5 @@ func rotateSecretsToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
c.JSON(http.StatusOK, SecretsTokenResponse{Token: token})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func elevated(c *gin.Context) bool {
|
||||
r := auth.Role(c)
|
||||
return r == models.RoleOwner || r == models.RoleAdmin
|
||||
}
|
||||
|
||||
// listTokens godoc
|
||||
//
|
||||
// @Summary List API tokens
|
||||
// @Description Returns the caller's own tokens. Owner and admin may pass all=true to see every token in the instance.
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Param all query bool false "Include every token in the instance (owner and admin only)"
|
||||
// @Success 200 {object} ListTokensResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens [get]
|
||||
func listTokens(c *gin.Context) {
|
||||
all := c.Query("all") == "true" && elevated(c)
|
||||
tokens, err := services.ListAPITokens(auth.InstanceID(c), auth.UserID(c), all)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ListTokensResponse{Tokens: tokens, All: all})
|
||||
}
|
||||
|
||||
// listTokenScopes godoc
|
||||
//
|
||||
// @Summary List available token scopes
|
||||
// @Description Advertises the scope vocabulary so the UI never hardcodes it.
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Success 200 {object} TokenScopesResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens/scopes [get]
|
||||
func listTokenScopes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, TokenScopesResponse{Scopes: services.AllScopes()})
|
||||
}
|
||||
|
||||
// createToken godoc
|
||||
//
|
||||
// @Summary Create an API token
|
||||
// @Description The plaintext token is returned exactly once and stored nowhere. A token's role and scopes cannot exceed the creator's own.
|
||||
// @Tags tokens
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body CreateTokenRequest true "Token parameters"
|
||||
// @Success 201 {object} CreateTokenResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 422 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens [post]
|
||||
func createToken(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
Scopes []string `json:"scopes" binding:"required"`
|
||||
ExpiresInDays *int `json:"expires_in_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tok, plaintext, err := services.CreateAPIToken(
|
||||
auth.InstanceID(c), auth.UserID(c),
|
||||
body.Name, body.Role, body.Scopes, body.ExpiresInDays, c.ClientIP(),
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, services.ErrTokenNameTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "name_taken"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenRoleTooHigh):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "role_too_high"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenExpiryPolicy):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error(), "code": "expiry_policy"})
|
||||
return
|
||||
case errors.Is(err, services.ErrInvalidScope):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "invalid_scope"})
|
||||
return
|
||||
case errors.Is(err, services.ErrTokenInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiry := "no expiry"
|
||||
if tok.ExpiresAt != nil {
|
||||
expiry = "expires " + tok.ExpiresAt.Format("2006-01-02")
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "token.created", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token '%s' created with role %s, scopes %v, %s", tok.Name, tok.Role, tok.Scopes, expiry))
|
||||
|
||||
// The plaintext is returned exactly once and is not stored anywhere.
|
||||
c.JSON(http.StatusCreated, CreateTokenResponse{Token: plaintext, Record: *tok})
|
||||
}
|
||||
|
||||
// revokeToken godoc
|
||||
//
|
||||
// @Summary Revoke an API token
|
||||
// @Tags tokens
|
||||
// @Produce json
|
||||
// @Param id path string true "Token ID"
|
||||
// @Success 200 {object} RevokedResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /tokens/{id} [delete]
|
||||
func revokeToken(c *gin.Context) {
|
||||
requester, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := services.RevokeAPIToken(auth.InstanceID(c), c.Param("id"), requester)
|
||||
if errors.Is(err, services.ErrTokenNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
services.LogEvent(auth.InstanceID(c), "token.revoked", actorFromCtx(c), "", "",
|
||||
fmt.Sprintf("API token '%s' revoked", tok.Name))
|
||||
c.JSON(http.StatusOK, RevokedResponse{Revoked: true})
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
)
|
||||
|
||||
// ErrorResponse is the shape every failing endpoint answers with. Some also
|
||||
// carry a machine-readable code; it is omitted when absent rather than empty.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// LimitExceededResponse is what a create route answers when a licence cap
|
||||
// would be exceeded.
|
||||
type LimitExceededResponse struct {
|
||||
Error string `json:"error"`
|
||||
Limit string `json:"limit"`
|
||||
Current int `json:"current"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
// LicenceErrorResponse pairs an error with a machine-readable reason rather
|
||||
// than a code — used only on the two licence rejection paths that predate the
|
||||
// error/code convention used everywhere else.
|
||||
type LicenceErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Small, reused acknowledgement shapes. Several unrelated handlers happen to
|
||||
// answer with exactly one of these.
|
||||
type DeletedResponse struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type RevokedResponse struct {
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
|
||||
type SavedResponse struct {
|
||||
Saved bool `json:"saved"`
|
||||
}
|
||||
|
||||
type AcknowledgedResponse struct {
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
}
|
||||
|
||||
type OKResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type CancelledResponse struct {
|
||||
Cancelled bool `json:"cancelled"`
|
||||
}
|
||||
|
||||
type UpdatedResponse struct {
|
||||
Updated bool `json:"updated"`
|
||||
}
|
||||
|
||||
type MessageResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// --- servers / keys ---
|
||||
|
||||
type TagsResponse struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type CreateServerResponse struct {
|
||||
Server *models.Server `json:"server"`
|
||||
Token string `json:"token"`
|
||||
ServerID string `json:"server_id"`
|
||||
}
|
||||
|
||||
type NewServerResponse struct {
|
||||
ServerID string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
InstallCommand string `json:"install_command"`
|
||||
InstallCommandPS string `json:"install_command_ps"`
|
||||
}
|
||||
|
||||
// ServerDetailResponse is a server with its resolved key assignments.
|
||||
type ServerDetailResponse struct {
|
||||
*models.Server
|
||||
Keys interface{} `json:"keys"`
|
||||
}
|
||||
|
||||
type GenerateKeyResponse struct {
|
||||
Message string `json:"message"`
|
||||
CommandID string `json:"command_id"`
|
||||
ServerID string `json:"server_id"`
|
||||
}
|
||||
|
||||
type PrivateKeyResponse struct {
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
// KeyDetailResponse is a key with its resolved server assignments.
|
||||
type KeyDetailResponse struct {
|
||||
*models.Key
|
||||
Assignments any `json:"assignments"`
|
||||
}
|
||||
|
||||
type AgentVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type UpdateAgentResponse struct {
|
||||
Message string `json:"message"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type AuditEventsResponse struct {
|
||||
Events []models.AuditEvent `json:"events"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// --- tokens ---
|
||||
|
||||
type ListTokensResponse struct {
|
||||
Tokens []models.APIToken `json:"tokens"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
|
||||
type TokenScopesResponse struct {
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
||||
}
|
||||
|
||||
type CreateTokenResponse struct {
|
||||
// Token is the plaintext, returned exactly once and stored nowhere.
|
||||
Token string `json:"token"`
|
||||
Record models.APIToken `json:"record"`
|
||||
}
|
||||
|
||||
// --- secrets ---
|
||||
|
||||
type GroupResponse struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
type SecretGroupResponse struct {
|
||||
Group string `json:"group"`
|
||||
Secrets []models.Secret `json:"secrets"`
|
||||
}
|
||||
|
||||
type RevealSecretResponse struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type SecretsTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// --- auth providers ---
|
||||
|
||||
type TestProviderResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// --- licence ---
|
||||
|
||||
type LicencePostResponse struct {
|
||||
State license.State `json:"state"`
|
||||
Tier string `json:"tier"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// --- vulnerabilities ---
|
||||
|
||||
// VulnSummaryResponse's four DB-freshness fields are only present at all when
|
||||
// a vulndb_meta document exists; LastError is separately omitted from that
|
||||
// group when empty, matching the handler's original conditional gin.H.
|
||||
type VulnSummaryResponse struct {
|
||||
Counts map[string]int `json:"counts"`
|
||||
DBVersion *int `json:"db_version,omitempty"`
|
||||
PulledAt *time.Time `json:"pulled_at,omitempty"`
|
||||
LastFullScanAt *time.Time `json:"last_full_scan_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type QueuedResponse struct {
|
||||
Queued int64 `json:"queued"`
|
||||
}
|
||||
|
||||
type ReportedResponse struct {
|
||||
Reported bool `json:"reported"`
|
||||
}
|
||||
|
||||
// --- workflows ---
|
||||
|
||||
type SeedDefaultsResponse struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
}
|
||||
|
||||
type RunWorkflowResponse struct {
|
||||
RunID string `json:"run_id"`
|
||||
}
|
||||
|
||||
type ScheduleResponse struct {
|
||||
Schedule models.Schedule `json:"schedule"`
|
||||
NextRunAt *time.Time `json:"next_run_at"`
|
||||
}
|
||||
|
||||
type OccurrencesResponse struct {
|
||||
Occurrences []time.Time `json:"occurrences"`
|
||||
}
|
||||
|
||||
// --- console ---
|
||||
|
||||
type ConsoleConnectResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Token string `json:"token"`
|
||||
WSPath string `json:"ws_path"`
|
||||
}
|
||||
|
||||
// --- workloads ---
|
||||
|
||||
type WorkloadLogsResponse struct {
|
||||
Text string `json:"text"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
@@ -26,6 +26,22 @@ type vulnGroup struct {
|
||||
Findings []models.VulnFinding `json:"findings"`
|
||||
}
|
||||
|
||||
// listVulnerabilities godoc
|
||||
//
|
||||
// @Summary List vulnerabilities
|
||||
// @Description Groups findings by CVE, most severe first — the same CVE on forty servers is one decision, not forty rows.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param severity query string false "Filter by severity"
|
||||
// @Param state query string false "Filter by state (default open)"
|
||||
// @Param server query string false "Filter by server ID"
|
||||
// @Param tag query []string false "Filter by tag as key:value, repeatable"
|
||||
// @Param has_fix query bool false "Filter by whether a vendor fix exists"
|
||||
// @Success 200 {array} vulnGroup
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities [get]
|
||||
func listVulnerabilities(c *gin.Context) {
|
||||
findings, err := services.ListInstanceFindings(auth.InstanceID(c), services.FindingFilter{
|
||||
Severity: c.Query("severity"),
|
||||
@@ -115,6 +131,17 @@ func tagsFromQuery(c *gin.Context) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// vulnerabilitySummary godoc
|
||||
//
|
||||
// @Summary Get vulnerability counts and database freshness
|
||||
// @Description Counts travel with the database version and pull time, since a fleet scanned against a stale database must say so wherever its findings are read.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {object} VulnSummaryResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/summary [get]
|
||||
func vulnerabilitySummary(c *gin.Context) {
|
||||
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -122,24 +149,32 @@ func vulnerabilitySummary(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := gin.H{"counts": counts}
|
||||
resp := VulnSummaryResponse{Counts: counts}
|
||||
|
||||
// Database freshness travels with the counts rather than living in
|
||||
// settings: a fleet scanned against a three-week-old database must say so
|
||||
// wherever its findings are read, not somewhere the reader has to go and
|
||||
// look for it.
|
||||
if meta, err := services.GetVulnDBMeta(); err == nil && meta != nil {
|
||||
resp["db_version"] = meta.DBVersion
|
||||
resp["pulled_at"] = meta.PulledAt
|
||||
resp["last_full_scan_at"] = meta.LastFullScanAt
|
||||
if meta.LastError != "" {
|
||||
resp["last_error"] = meta.LastError
|
||||
}
|
||||
resp.DBVersion = &meta.DBVersion
|
||||
resp.PulledAt = &meta.PulledAt
|
||||
resp.LastFullScanAt = &meta.LastFullScanAt
|
||||
resp.LastError = meta.LastError
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// rescanVulnerabilities godoc
|
||||
//
|
||||
// @Summary Queue the fleet for a vulnerability rescan
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {object} QueuedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/rescan [post]
|
||||
func rescanVulnerabilities(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
|
||||
@@ -151,7 +186,7 @@ func rescanVulnerabilities(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rescan", actorFromCtx(c), "", "",
|
||||
"queued "+strconv.FormatInt(n, 10)+" server(s) for rescan")
|
||||
c.JSON(http.StatusOK, gin.H{"queued": n})
|
||||
c.JSON(http.StatusOK, QueuedResponse{Queued: n})
|
||||
}
|
||||
|
||||
type acceptFindingRequest struct {
|
||||
@@ -159,6 +194,22 @@ type acceptFindingRequest struct {
|
||||
Until time.Time `json:"until"`
|
||||
}
|
||||
|
||||
// acceptFinding godoc
|
||||
//
|
||||
// @Summary Accept a finding
|
||||
// @Description Requires a reason and a future expiry. Reopens automatically at expiry — permanent dismissal is never allowed.
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Finding ID"
|
||||
// @Param body body acceptFindingRequest true "Reason and expiry"
|
||||
// @Success 200 {object} models.VulnFinding
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/{id}/accept [post]
|
||||
func acceptFinding(c *gin.Context) {
|
||||
var req acceptFindingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -192,6 +243,18 @@ func acceptFinding(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, f)
|
||||
}
|
||||
|
||||
// unacceptFinding godoc
|
||||
//
|
||||
// @Summary Return an accepted finding to open
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Finding ID"
|
||||
// @Success 200 {object} models.VulnFinding
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vulnerabilities/{id}/accept [delete]
|
||||
func unacceptFinding(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
actor := actorFromCtx(c)
|
||||
@@ -215,6 +278,17 @@ func writeFindingError(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
|
||||
// listServerVulnerabilities godoc
|
||||
//
|
||||
// @Summary List a server's vulnerabilities
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {array} models.VulnFinding
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/vulnerabilities [get]
|
||||
func listServerVulnerabilities(c *gin.Context) {
|
||||
findings, err := services.ListFindings(c.Request.Context(), auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -227,6 +301,18 @@ func listServerVulnerabilities(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, findings)
|
||||
}
|
||||
|
||||
// getServerPackages godoc
|
||||
//
|
||||
// @Summary Get a server's package inventory
|
||||
// @Description A server that has not reported yet answers reported=false rather than 404 — that is the normal state for the first hour after install.
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} models.ServerPackages
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/packages [get]
|
||||
func getServerPackages(c *gin.Context) {
|
||||
sp, err := services.ListPackages(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -237,12 +323,24 @@ func getServerPackages(c *gin.Context) {
|
||||
// Not a 404: an agent that has not reported yet is the normal state for
|
||||
// the first hour after install, and is a different thing from a bad
|
||||
// server id.
|
||||
c.JSON(http.StatusOK, gin.H{"reported": false})
|
||||
c.JSON(http.StatusOK, ReportedResponse{Reported: false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sp)
|
||||
}
|
||||
|
||||
// searchPackages godoc
|
||||
//
|
||||
// @Summary Search packages fleet-wide
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param name query string true "Package name"
|
||||
// @Success 200 {array} services.PackageHit
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /packages/search [get]
|
||||
func searchPackages(c *gin.Context) {
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
@@ -257,6 +355,16 @@ func searchPackages(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, hits)
|
||||
}
|
||||
|
||||
// listVulnRules godoc
|
||||
//
|
||||
// @Summary List vulnerability alert rules
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.VulnAlertRule
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules [get]
|
||||
func listVulnRules(c *gin.Context) {
|
||||
rules, err := services.ListVulnRules(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -266,6 +374,18 @@ func listVulnRules(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, rules)
|
||||
}
|
||||
|
||||
// createVulnRule godoc
|
||||
//
|
||||
// @Summary Create a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.VulnAlertRule true "Rule to create"
|
||||
// @Success 201 {object} models.VulnAlertRule
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules [post]
|
||||
func createVulnRule(c *gin.Context) {
|
||||
var r models.VulnAlertRule
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
@@ -284,6 +404,20 @@ func createVulnRule(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// updateVulnRule godoc
|
||||
//
|
||||
// @Summary Update a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Rule ID"
|
||||
// @Param body body models.VulnAlertRule true "Rule fields"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules/{id} [put]
|
||||
func updateVulnRule(c *gin.Context) {
|
||||
var r models.VulnAlertRule
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
@@ -302,9 +436,21 @@ func updateVulnRule(c *gin.Context) {
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rule_updated", actorFromCtx(c), "", "", "rule "+r.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "updated"})
|
||||
}
|
||||
|
||||
// deleteVulnRule godoc
|
||||
//
|
||||
// @Summary Delete a vulnerability alert rule
|
||||
// @Tags vulnerabilities
|
||||
// @Produce json
|
||||
// @Param id path string true "Rule ID"
|
||||
// @Success 200 {object} StatusResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /vuln-rules/{id} [delete]
|
||||
func deleteVulnRule(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
if err := services.DeleteVulnRule(instanceID, c.Param("id")); err != nil {
|
||||
@@ -317,5 +463,5 @@ func deleteVulnRule(c *gin.Context) {
|
||||
}
|
||||
|
||||
services.LogEvent(instanceID, "vuln.rule_deleted", actorFromCtx(c), "", "", "rule "+c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
c.JSON(http.StatusOK, StatusResponse{Status: "deleted"})
|
||||
}
|
||||
|
||||
@@ -47,6 +47,20 @@ func registerWorkflowRoutes(g *gin.RouterGroup) {
|
||||
|
||||
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
|
||||
|
||||
// getServerRunLog godoc
|
||||
//
|
||||
// @Summary Get a run's log for one server
|
||||
// @Description Streams the stored log in pages rather than loading it whole; capped at 200k lines per server-run.
|
||||
// @Tags workflows
|
||||
// @Produce plain
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {string} string "plain-text log"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/servers/{serverId}/logs [get]
|
||||
func getServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
@@ -83,6 +97,20 @@ func getServerRunLog(c *gin.Context) {
|
||||
// is one or two queries, small enough that no single response buffers much.
|
||||
const logPageSize = 2000
|
||||
|
||||
// streamServerRunLog godoc
|
||||
//
|
||||
// @Summary Stream a run's log for one server (SSE)
|
||||
// @Description Server-sent events; sends new lines every 500ms until the server's run reaches a terminal state.
|
||||
// @Tags workflows
|
||||
// @Produce text/event-stream
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Param serverId path string true "Server ID"
|
||||
// @Success 200 {string} string "text/event-stream"
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/servers/{serverId}/logs/stream [get]
|
||||
func streamServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
@@ -166,6 +194,16 @@ func splitSSE(b []byte) []string {
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
// listSteps godoc
|
||||
//
|
||||
// @Summary List workflow steps
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.WorkflowStep
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps [get]
|
||||
func listSteps(c *gin.Context) {
|
||||
steps, err := services.ListSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -175,6 +213,16 @@ func listSteps(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, steps)
|
||||
}
|
||||
|
||||
// stepUsage godoc
|
||||
//
|
||||
// @Summary Count workflows using each step
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]int
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/usage [get]
|
||||
func stepUsage(c *gin.Context) {
|
||||
counts, err := services.StepUsageCounts(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -184,6 +232,19 @@ func stepUsage(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
|
||||
// createStep godoc
|
||||
//
|
||||
// @Summary Create a workflow step
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Step to create"
|
||||
// @Success 201 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps [post]
|
||||
func createStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
@@ -199,6 +260,22 @@ func createStep(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// updateStep godoc
|
||||
//
|
||||
// @Summary Update a workflow step
|
||||
// @Description A step with source "default" is read-only and refuses with 409, because seeding rewrites it on every boot.
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Param body body models.WorkflowStep true "Step fields"
|
||||
// @Success 200 {object} UpdatedResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id} [put]
|
||||
func updateStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
@@ -214,9 +291,22 @@ func updateStep(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
c.JSON(http.StatusOK, UpdatedResponse{Updated: true})
|
||||
}
|
||||
|
||||
// deleteStep godoc
|
||||
//
|
||||
// @Summary Delete a workflow step
|
||||
// @Description A step with source "default" is read-only and refuses with 409.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id} [delete]
|
||||
func deleteStep(c *gin.Context) {
|
||||
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
if errors.Is(err, services.ErrDefaultStep) {
|
||||
@@ -227,9 +317,20 @@ func deleteStep(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// exportStep godoc
|
||||
//
|
||||
// @Summary Export a step as a downloadable JSON document
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Step ID"
|
||||
// @Success 200 {object} models.WorkflowStep
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/{id}/export [get]
|
||||
func exportStep(c *gin.Context) {
|
||||
b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -240,6 +341,16 @@ func exportStep(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
// seedDefaults godoc
|
||||
//
|
||||
// @Summary Sync the default step library
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {object} SeedDefaultsResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/seed-defaults [post]
|
||||
func seedDefaults(c *gin.Context) {
|
||||
created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -247,11 +358,23 @@ func seedDefaults(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
|
||||
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
|
||||
c.JSON(http.StatusOK, SeedDefaultsResponse{Created: created, Updated: updated})
|
||||
}
|
||||
|
||||
const maxStepBodyBytes = 1 << 20
|
||||
|
||||
// importStep godoc
|
||||
//
|
||||
// @Summary Import a step from an exported JSON document
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Exported step document"
|
||||
// @Success 201 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/import [post]
|
||||
func importStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
@@ -268,6 +391,18 @@ func importStep(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// parseStep godoc
|
||||
//
|
||||
// @Summary Parse a step document without saving it
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.WorkflowStep true "Step document to parse"
|
||||
// @Success 200 {object} models.WorkflowStep
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /steps/parse [post]
|
||||
func parseStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
@@ -283,6 +418,16 @@ func parseStep(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// listWorkflows godoc
|
||||
//
|
||||
// @Summary List workflows
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Workflow
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows [get]
|
||||
func listWorkflows(c *gin.Context) {
|
||||
wfs, err := services.ListWorkflows(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
@@ -292,6 +437,19 @@ func listWorkflows(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, wfs)
|
||||
}
|
||||
|
||||
// createWorkflow godoc
|
||||
//
|
||||
// @Summary Create a workflow
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.Workflow true "Workflow to create"
|
||||
// @Success 201 {object} models.Workflow
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows [post]
|
||||
func createWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
@@ -307,6 +465,17 @@ func createWorkflow(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// getWorkflow godoc
|
||||
//
|
||||
// @Summary Get a workflow
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 200 {object} models.Workflow
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [get]
|
||||
func getWorkflow(c *gin.Context) {
|
||||
w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -316,6 +485,20 @@ func getWorkflow(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, w)
|
||||
}
|
||||
|
||||
// updateWorkflow godoc
|
||||
//
|
||||
// @Summary Update a workflow
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param body body models.Workflow true "Workflow fields"
|
||||
// @Success 200 {object} models.Workflow
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [put]
|
||||
func updateWorkflow(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
@@ -335,15 +518,39 @@ func updateWorkflow(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteWorkflow godoc
|
||||
//
|
||||
// @Summary Delete a workflow
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 200 {object} DeletedResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id} [delete]
|
||||
func deleteWorkflow(c *gin.Context) {
|
||||
if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
c.JSON(http.StatusOK, DeletedResponse{Deleted: true})
|
||||
}
|
||||
|
||||
// runWorkflow godoc
|
||||
//
|
||||
// @Summary Run a workflow
|
||||
// @Description Snapshots the resolved steps into a WorkflowRun and dispatches to every targeted server.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Success 202 {object} RunWorkflowResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/run [post]
|
||||
func runWorkflow(c *gin.Context) {
|
||||
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
|
||||
if err != nil {
|
||||
@@ -355,9 +562,21 @@ func runWorkflow(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
|
||||
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
|
||||
c.JSON(http.StatusAccepted, RunWorkflowResponse{RunID: runID})
|
||||
}
|
||||
|
||||
// listWorkflowRuns godoc
|
||||
//
|
||||
// @Summary List a workflow's runs
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param limit query int false "Max runs to return (default 50)"
|
||||
// @Success 200 {array} models.WorkflowRun
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/runs [get]
|
||||
func listWorkflowRuns(c *gin.Context) {
|
||||
limit := int64(50)
|
||||
if l := c.Query("limit"); l != "" {
|
||||
@@ -373,6 +592,17 @@ func listWorkflowRuns(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, runs)
|
||||
}
|
||||
|
||||
// getRun godoc
|
||||
//
|
||||
// @Summary Get a run
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Success 200 {object} models.WorkflowRun
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId} [get]
|
||||
func getRun(c *gin.Context) {
|
||||
r, err := services.GetRun(auth.InstanceID(c), c.Param("runId"))
|
||||
if err != nil {
|
||||
@@ -382,15 +612,42 @@ func getRun(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, r)
|
||||
}
|
||||
|
||||
// cancelRun godoc
|
||||
//
|
||||
// @Summary Cancel a run
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param runId path string true "Run ID"
|
||||
// @Success 200 {object} CancelledResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /runs/{runId}/cancel [post]
|
||||
func cancelRun(c *gin.Context) {
|
||||
if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
|
||||
c.JSON(http.StatusOK, gin.H{"cancelled": true})
|
||||
c.JSON(http.StatusOK, CancelledResponse{Cancelled: true})
|
||||
}
|
||||
|
||||
// putWorkflowSchedule godoc
|
||||
//
|
||||
// @Summary Set a workflow's schedule
|
||||
// @Description Standard 5-field cron and an IANA zone, both validated at save time.
|
||||
// @Tags workflows
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workflow ID"
|
||||
// @Param body body models.Schedule true "Schedule"
|
||||
// @Success 200 {object} ScheduleResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/schedule [put]
|
||||
func putWorkflowSchedule(c *gin.Context) {
|
||||
var body models.Schedule
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
@@ -415,12 +672,22 @@ func putWorkflowSchedule(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "workflow.schedule_updated", actorFromCtx(c), "", c.Param("id"),
|
||||
fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled))
|
||||
c.JSON(http.StatusOK, gin.H{"schedule": body, "next_run_at": next})
|
||||
c.JSON(http.StatusOK, ScheduleResponse{Schedule: body, NextRunAt: next})
|
||||
}
|
||||
|
||||
// previewWorkflowSchedule exists so the browser and the scheduler agree on
|
||||
// what a cron string means. A client-side cron parser that disagrees with the
|
||||
// server by one field is a bug found in production, at night.
|
||||
// previewWorkflowSchedule godoc
|
||||
//
|
||||
// @Summary Preview the next occurrences of a cron schedule
|
||||
// @Description Exists so the browser and the scheduler agree on what a cron string means.
|
||||
// @Tags workflows
|
||||
// @Produce json
|
||||
// @Param cron query string true "5-field cron expression"
|
||||
// @Param tz query string true "IANA time zone name"
|
||||
// @Success 200 {object} OccurrencesResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workflows/{id}/schedule/preview [get]
|
||||
func previewWorkflowSchedule(c *gin.Context) {
|
||||
expr := c.Query("cron")
|
||||
tz := c.Query("tz")
|
||||
|
||||
@@ -18,6 +18,19 @@ import (
|
||||
//
|
||||
// A server that has never reported answers an empty list rather than 404: the
|
||||
// agent may simply not have got there yet, and 404 reads as "no such server".
|
||||
// getServerWorkloads godoc
|
||||
//
|
||||
// @Summary Get a server's workload snapshot
|
||||
// @Description Returns the stored snapshot. A server that has never reported answers an empty list, not 404.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 200 {object} models.ServerWorkloads
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads [get]
|
||||
func getServerWorkloads(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
@@ -47,6 +60,19 @@ func getServerWorkloads(c *gin.Context) {
|
||||
|
||||
// refreshServerWorkloads nudges the agent to report now. It returns no data:
|
||||
// the client refetches the stored document once the agent has written it.
|
||||
// refreshServerWorkloads godoc
|
||||
//
|
||||
// @Summary Request a fresh workload report
|
||||
// @Description Nudges the agent to report now. Returns no data; the client refetches once the agent has written it.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Success 202 {object} MessageResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/refresh [post]
|
||||
func refreshServerWorkloads(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
@@ -63,9 +89,28 @@ func refreshServerWorkloads(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"})
|
||||
c.JSON(http.StatusAccepted, MessageResponse{Message: "refresh requested"})
|
||||
}
|
||||
|
||||
// controlWorkload godoc
|
||||
//
|
||||
// @Summary Start, stop or restart a workload
|
||||
// @Description Owner and admin only. The protected set (vantage-agent.service and the agent's own container) is enforced agent-side and answers 409, not an error.
|
||||
// @Tags workloads
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param wid path string true "Workload ID"
|
||||
// @Param body body object{action=string,kind=string} true "Action (start/stop/restart) and kind (container/unit)"
|
||||
// @Success 200 {object} MessageResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/{wid}/action [post]
|
||||
func controlWorkload(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
@@ -117,9 +162,27 @@ func controlWorkload(c *gin.Context) {
|
||||
|
||||
services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "",
|
||||
fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname))
|
||||
c.JSON(http.StatusOK, gin.H{"message": body.Action + " ok"})
|
||||
c.JSON(http.StatusOK, MessageResponse{Message: body.Action + " ok"})
|
||||
}
|
||||
|
||||
// getWorkloadLogs godoc
|
||||
//
|
||||
// @Summary Read a workload's logs
|
||||
// @Description Owner and admin only, and audited: container output is arbitrary and cannot be masked. Capped at 500 lines and 256KB, whichever binds first.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param wid path string true "Workload ID"
|
||||
// @Param kind query string false "container or unit (default container)"
|
||||
// @Param tail query int false "Lines to return, clamped to the cap"
|
||||
// @Success 200 {object} WorkloadLogsResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 502 {object} ErrorResponse
|
||||
// @Failure 503 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /servers/{id}/workloads/{wid}/logs [get]
|
||||
func getWorkloadLogs(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
id := c.Param("id")
|
||||
@@ -163,11 +226,23 @@ func getWorkloadLogs(c *gin.Context) {
|
||||
services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "",
|
||||
fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"text": text, "truncated": truncated})
|
||||
c.JSON(http.StatusOK, WorkloadLogsResponse{Text: text, Truncated: truncated})
|
||||
}
|
||||
|
||||
// listWorkloads answers the fleet-wide question, which is the reason the
|
||||
// snapshot is stored rather than fetched on demand and discarded.
|
||||
// listWorkloads godoc
|
||||
//
|
||||
// @Summary Search workloads fleet-wide
|
||||
// @Description Answers the fleet-wide question, which is the reason the snapshot is stored rather than fetched on demand and discarded.
|
||||
// @Tags workloads
|
||||
// @Produce json
|
||||
// @Param image query string false "Filter by image name"
|
||||
// @Param stack query string false "Filter by compose stack"
|
||||
// @Param state query string false "Filter by state"
|
||||
// @Success 200 {array} services.WorkloadHit
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /workloads [get]
|
||||
func listWorkloads(c *gin.Context) {
|
||||
hits, err := services.SearchWorkloads(auth.InstanceID(c),
|
||||
c.Query("image"), c.Query("stack"), c.Query("state"))
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -48,17 +52,27 @@ func RequireRole(roles ...string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware authenticates a request by session cookie or by API token.
|
||||
//
|
||||
// Both paths end by putting a *Session in the context, which is why no handler,
|
||||
// role guard, licence gate or audit call needed changing: the token path is a
|
||||
// second way to arrive at the same value, not a second way through the API.
|
||||
func Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
sess, ok := sessionFromCookie(c)
|
||||
if !ok {
|
||||
// A cookie that was presented and rejected has already had its
|
||||
// response written by sessionFromCookie (no bearer was present to
|
||||
// fall through to). Trying sessionFromToken anyway would write a
|
||||
// second body onto the same response.
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
sess, ok = sessionFromToken(c)
|
||||
}
|
||||
|
||||
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
if !ok {
|
||||
// sessionFromCookie and sessionFromToken have already written the
|
||||
// response describing which credential failed and why.
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,6 +83,8 @@ func Middleware() gin.HandlerFunc {
|
||||
|
||||
c.Set(ctxSessionKey, sess)
|
||||
|
||||
// The host guard applies to both credential kinds. A token carries an
|
||||
// instance, and the tenant boundary must not have a token-shaped hole.
|
||||
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
|
||||
return
|
||||
@@ -77,3 +93,105 @@ func Middleware() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// sessionFromCookie returns false without writing a response when there is no
|
||||
// cookie at all, so the token path gets its turn. It writes and aborts only
|
||||
// when a cookie was presented and was not usable.
|
||||
func sessionFromCookie(c *gin.Context) (*Session, bool) {
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
// A stale cookie plus a valid bearer token is a real combination —
|
||||
// a browser tab left open beside a curl. Fall through rather than
|
||||
// refusing a credential that would have worked.
|
||||
if bearerToken(c) != "" {
|
||||
return nil, false
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
func bearerToken(c *gin.Context) string {
|
||||
const prefix = "Bearer "
|
||||
h := c.GetHeader("Authorization")
|
||||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return ""
|
||||
}
|
||||
return h[len(prefix):]
|
||||
}
|
||||
|
||||
func sessionFromToken(c *gin.Context) (*Session, bool) {
|
||||
raw := bearerToken(c)
|
||||
if raw == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
tok, err := services.ResolveAPIToken(raw)
|
||||
if errors.Is(err, services.ErrTokenExpired) {
|
||||
// Recorded rather than only refused: an expired token still being
|
||||
// presented is how a forgotten CI job becomes visible.
|
||||
services.LogEvent(tok.InstanceID, "token.expired_use", tok.Name, "", "",
|
||||
fmt.Sprintf("expired token '%s' was used", tok.Name))
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token expired", "code": "token_expired"})
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err := services.GetUserInInstance(tok.InstanceID, tok.UserID)
|
||||
if err != nil {
|
||||
// The owner is gone. DeleteUser removes tokens, so this is the
|
||||
// belt-and-braces path for a row deleted some other way.
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
services.TouchAPIToken(tok)
|
||||
|
||||
return &Session{
|
||||
UserID: tok.UserID,
|
||||
InstanceID: tok.InstanceID,
|
||||
// Recomputed per request, so demoting the person demotes the token.
|
||||
Role: services.LowerRole(user.Role, tok.Role),
|
||||
Email: user.Email,
|
||||
Name: user.Email,
|
||||
TokenID: tok.TokenID,
|
||||
TokenName: tok.Name,
|
||||
Scopes: tok.Scopes,
|
||||
}, true
|
||||
}
|
||||
|
||||
// TokenID is empty for a cookie session and the token's ID for a token
|
||||
// request. It is what lets audit detail record which credential acted.
|
||||
func TokenID(c *gin.Context) string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.TokenID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TokenName(c *gin.Context) string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.TokenName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Scopes(c *gin.Context) []string {
|
||||
if s := GetSessionFromContext(c); s != nil {
|
||||
return s.Scopes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsToken reports whether this request authenticated with an API token rather
|
||||
// than a browser session.
|
||||
func IsToken(c *gin.Context) bool { return TokenID(c) != "" }
|
||||
|
||||
@@ -22,6 +22,14 @@ type Session struct {
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
|
||||
// The three fields below are set only when the request authenticated with
|
||||
// an API token. They are never persisted to Redis — a token authenticates
|
||||
// per request and mints no session, so a revoked token stops working
|
||||
// immediately rather than at the end of a session TTL.
|
||||
TokenID string `json:"-"`
|
||||
TokenName string `json:"-"`
|
||||
Scopes []string `json:"-"`
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
@@ -51,6 +59,11 @@ func PingRedis(ctx context.Context) error {
|
||||
return rdb.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// Redis exposes the session client for callers that need a counter rather than
|
||||
// a session. There is one Redis in this deployment and adding a second client
|
||||
// would double the connection pool for no reason.
|
||||
func Redis() *redis.Client { return rdb }
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// APIToken is a personal access token for the REST API.
|
||||
//
|
||||
// The plaintext is shown once at creation and never stored: only TokenHash,
|
||||
// which is sha256 hex of the value, exactly as servers.agent_token_hash and the
|
||||
// ESO read token already are. bcrypt is deliberately not used — the value is
|
||||
// full-entropy random rather than a chosen password, and a per-token salt would
|
||||
// force a collection scan where an indexed lookup is wanted.
|
||||
//
|
||||
// Role and Scopes are immutable after creation. There is no update endpoint:
|
||||
// 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.
|
||||
type APIToken struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
TokenID string `bson:"token_id" json:"token_id"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
UserID string `bson:"user_id" json:"user_id"`
|
||||
|
||||
Name string `bson:"name" json:"name"`
|
||||
// Hint is the first 8 characters of the plaintext, stored in clear so the
|
||||
// list can identify a token without revealing it.
|
||||
Hint string `bson:"hint" json:"hint"`
|
||||
// TokenHash is never serialised to JSON.
|
||||
TokenHash string `bson:"token_hash" json:"-"`
|
||||
|
||||
Role string `bson:"role" json:"role"`
|
||||
Scopes []string `bson:"scopes" json:"scopes"`
|
||||
|
||||
// ExpiresAt nil means the token never expires. Whether that is allowed is
|
||||
// a per-instance policy, settings.api_token_max_days.
|
||||
ExpiresAt *time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"`
|
||||
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
|
||||
CreatedByIP string `bson:"created_by_ip,omitempty" json:"created_by_ip,omitempty"`
|
||||
|
||||
// Email of the owning user, joined at read time for the list. Never stored.
|
||||
UserEmail string `bson:"-" json:"user_email,omitempty"`
|
||||
}
|
||||
|
||||
// Expired reports whether the token's expiry has passed. A nil ExpiresAt never
|
||||
// expires.
|
||||
func (t *APIToken) Expired(now time.Time) bool {
|
||||
return t.ExpiresAt != nil && now.After(*t.ExpiresAt)
|
||||
}
|
||||
@@ -7,3 +7,7 @@ type (
|
||||
AlertSettings = shared.AlertSettings
|
||||
SecretsSettings = shared.SecretsSettings
|
||||
)
|
||||
|
||||
// APITokenMaxDays re-exports shared.APITokenMaxDays so server/internal/services
|
||||
// can read the token lifetime cap without importing shared/models directly.
|
||||
func APITokenMaxDays(s *Settings) int { return shared.APITokenMaxDays(s) }
|
||||
|
||||
@@ -43,6 +43,7 @@ var ScopedCollections = []string{
|
||||
"server_packages",
|
||||
"vuln_findings",
|
||||
"vuln_alert_rules",
|
||||
"api_tokens",
|
||||
"server_workloads",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidScope is returned when a token is requested with a scope outside
|
||||
// the vocabulary below.
|
||||
var ErrInvalidScope = errors.New("invalid scope")
|
||||
|
||||
// ScopeResources is the whole vocabulary. Eight resources, each with :read and
|
||||
// :write, and write implies read on the same resource.
|
||||
//
|
||||
// It is deliberately coarse. A scope per endpoint is a table nobody maintains,
|
||||
// and a route added without an entry either fails closed and breaks, or
|
||||
// defaults open and is pointless.
|
||||
var ScopeResources = []string{
|
||||
"servers",
|
||||
"keys",
|
||||
"secrets",
|
||||
"workflows",
|
||||
"monitors",
|
||||
"vulns",
|
||||
"workloads",
|
||||
"settings",
|
||||
}
|
||||
|
||||
const (
|
||||
ScopeRead = "read"
|
||||
ScopeWrite = "write"
|
||||
)
|
||||
|
||||
// AllScopes returns every valid scope string, sorted, for the API to advertise
|
||||
// to the token-creation UI.
|
||||
func AllScopes() []string {
|
||||
out := make([]string, 0, len(ScopeResources)*2)
|
||||
for _, r := range ScopeResources {
|
||||
out = append(out, r+":"+ScopeRead, r+":"+ScopeWrite)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func validScope(s string) bool {
|
||||
resource, action, ok := strings.Cut(s, ":")
|
||||
if !ok || (action != ScopeRead && action != ScopeWrite) {
|
||||
return false
|
||||
}
|
||||
for _, r := range ScopeResources {
|
||||
if r == resource {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidScopes rejects an unknown scope and an empty list. A token with no
|
||||
// scopes can reach nothing, so creating one is a mistake worth naming rather
|
||||
// than a credential worth issuing.
|
||||
func ValidScopes(scopes []string) error {
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Errorf("%w: at least one scope is required", ErrInvalidScope)
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if !validScope(s) {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidScope, s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScopeSatisfied reports whether the held scopes cover the required one.
|
||||
// Holding "servers:write" satisfies a requirement of "servers:read"; the
|
||||
// converse is false.
|
||||
func ScopeSatisfied(held []string, required string) bool {
|
||||
resource, action, ok := strings.Cut(required, ":")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, h := range held {
|
||||
if h == required {
|
||||
return true
|
||||
}
|
||||
if action == ScopeRead && h == resource+":"+ScopeWrite {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func ResolveSecretsReadToken(token string) (string, bool) {
|
||||
return s.InstanceID, true
|
||||
}
|
||||
|
||||
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool) error {
|
||||
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, apiTokenMaxDays *int) error {
|
||||
if alerts.OfflineThresholdMinutes <= 0 {
|
||||
alerts.OfflineThresholdMinutes = 5
|
||||
}
|
||||
@@ -149,6 +149,9 @@ func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays
|
||||
if localLoginEnabled != nil {
|
||||
set["local_login_enabled"] = *localLoginEnabled
|
||||
}
|
||||
if apiTokenMaxDays != nil {
|
||||
set["api_token_max_days"] = *apiTokenMaxDays
|
||||
}
|
||||
_, err := db.Col("settings").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": set, "$setOnInsert": bson.M{"instance_id": instanceID}},
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// EnsureAPITokenIndexes declares the indexes the token path depends on.
|
||||
//
|
||||
// The unique index on token_hash is a security property, not an optimisation:
|
||||
// it is what makes authentication a single indexed lookup rather than a scan,
|
||||
// and what makes two tokens hashing to one value impossible to store.
|
||||
//
|
||||
// Fatal on failure, like EnsureAuthIndexes and unlike the secrets and workflow
|
||||
// builders: without the unique index the auth path would still answer, which is
|
||||
// exactly the wrong kind of degradation.
|
||||
func EnsureAPITokenIndexes() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := db.Col("api_tokens").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "token_hash", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Col("api_tokens").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenNameTaken = errors.New("a token with that name already exists")
|
||||
ErrTokenRoleTooHigh = errors.New("cannot create a token above your own role")
|
||||
ErrTokenExpiryPolicy = errors.New("expiry exceeds this instance's maximum token lifetime")
|
||||
// ErrTokenInvalid marks a caller mistake as distinct from a backend
|
||||
// failure, which is what lets the handler choose 400 or 500.
|
||||
ErrTokenInvalid = errors.New("invalid token request")
|
||||
)
|
||||
|
||||
// TokenPrefix is on every plaintext so a leaked value is recognisable in a log
|
||||
// or a paste, and so a wrong credential fails at the prefix check rather than
|
||||
// as an anonymous 401.
|
||||
const TokenPrefix = "vt_"
|
||||
|
||||
const tokenNameMax = 64
|
||||
|
||||
// roleRank orders the three roles so a token can be capped at its owner's.
|
||||
func roleRank(role string) int {
|
||||
switch role {
|
||||
case models.RoleOwner:
|
||||
return 3
|
||||
case models.RoleAdmin:
|
||||
return 2
|
||||
case models.RoleMember:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LowerRole returns whichever of the two roles grants less. It is what makes a
|
||||
// token's authority follow its owner: demote the person and the token demotes
|
||||
// with them, because this is recomputed on every request rather than frozen at
|
||||
// creation.
|
||||
func LowerRole(a, b string) string {
|
||||
if roleRank(a) <= roleRank(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// CreateAPIToken mints a token and returns the document plus the plaintext.
|
||||
// The plaintext is the only copy: it is returned once and never stored.
|
||||
func CreateAPIToken(instanceID, userID, name, role string, scopes []string, expiresInDays *int, ip string) (*models.APIToken, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > tokenNameMax {
|
||||
return nil, "", fmt.Errorf("%w: token name must be 1 to %d characters", ErrTokenInvalid, tokenNameMax)
|
||||
}
|
||||
if !models.ValidRole(role) {
|
||||
return nil, "", fmt.Errorf("%w: invalid role %q", ErrTokenInvalid, role)
|
||||
}
|
||||
if err := ValidScopes(scopes); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
owner, err := GetUserInInstance(instanceID, userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: user not found", ErrTokenInvalid)
|
||||
}
|
||||
if roleRank(role) > roleRank(owner.Role) {
|
||||
return nil, "", ErrTokenRoleTooHigh
|
||||
}
|
||||
|
||||
settings, err := GetSettings(instanceID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
maxDays := models.APITokenMaxDays(settings)
|
||||
|
||||
var expiresAt *time.Time
|
||||
switch {
|
||||
case expiresInDays != nil:
|
||||
if *expiresInDays <= 0 {
|
||||
return nil, "", fmt.Errorf("%w: expires_in_days must be positive", ErrTokenInvalid)
|
||||
}
|
||||
if maxDays > 0 && *expiresInDays > maxDays {
|
||||
return nil, "", fmt.Errorf("%w: maximum is %d day(s)", ErrTokenExpiryPolicy, maxDays)
|
||||
}
|
||||
t := time.Now().UTC().AddDate(0, 0, *expiresInDays)
|
||||
expiresAt = &t
|
||||
case maxDays > 0:
|
||||
// A policy is set, so a token with no expiry is refused rather than
|
||||
// silently capped: the caller asked for something the instance does not
|
||||
// allow, and quietly giving them something else is worse than a 422.
|
||||
return nil, "", fmt.Errorf("%w: an expiry of at most %d day(s) is required", ErrTokenExpiryPolicy, maxDays)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
existing := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID, "name": name})
|
||||
if existing.Err() == nil {
|
||||
return nil, "", ErrTokenNameTaken
|
||||
} else if !errors.Is(existing.Err(), mongo.ErrNoDocuments) {
|
||||
return nil, "", existing.Err()
|
||||
}
|
||||
|
||||
secret, err := generateToken(32)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
plaintext := TokenPrefix + secret
|
||||
|
||||
tok := &models.APIToken{
|
||||
TokenID: uuid.NewString(),
|
||||
InstanceID: instanceID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Hint: plaintext[:8],
|
||||
TokenHash: HashToken(plaintext),
|
||||
Role: role,
|
||||
Scopes: scopes,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedByIP: ip,
|
||||
}
|
||||
|
||||
if _, err := db.Col("api_tokens").InsertOne(ctx, tok); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return tok, plaintext, nil
|
||||
}
|
||||
|
||||
// ResolveAPIToken looks a plaintext up by hash.
|
||||
//
|
||||
// It returns ErrTokenExpired distinctly from ErrTokenNotFound so the auth layer
|
||||
// can say which happened: a forgotten CI job hitting an expired token is worth
|
||||
// seeing in the audit log, and an anonymous 401 hides it.
|
||||
func ResolveAPIToken(plaintext string) (*models.APIToken, error) {
|
||||
if !strings.HasPrefix(plaintext, TokenPrefix) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tok models.APIToken
|
||||
err := db.Col("api_tokens").FindOne(ctx, bson.M{"token_hash": HashToken(plaintext)}).Decode(&tok)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tok.Expired(time.Now().UTC()) {
|
||||
return &tok, ErrTokenExpired
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// TouchAPIToken records use, but only when the stored value is more than a
|
||||
// minute stale. Without the check this is a Mongo write on every API call.
|
||||
func TouchAPIToken(tok *models.APIToken) {
|
||||
now := time.Now().UTC()
|
||||
if tok.LastUsedAt != nil && now.Sub(*tok.LastUsedAt) < time.Minute {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, _ = db.Col("api_tokens").UpdateOne(ctx,
|
||||
bson.M{"token_id": tok.TokenID, "instance_id": tok.InstanceID},
|
||||
bson.M{"$set": bson.M{"last_used_at": now}},
|
||||
)
|
||||
tok.LastUsedAt = &now
|
||||
}
|
||||
|
||||
// ListAPITokens returns a user's own tokens, or every token in the instance
|
||||
// when all is true. The caller decides whether all is permitted.
|
||||
func ListAPITokens(instanceID string, userID string, all bool) ([]models.APIToken, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
if !all {
|
||||
filter["user_id"] = userID
|
||||
}
|
||||
cursor, err := db.Col("api_tokens").Find(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var tokens []models.APIToken
|
||||
if err := cursor.All(ctx, &tokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokens == nil {
|
||||
tokens = []models.APIToken{}
|
||||
}
|
||||
|
||||
// Join the owning email so an admin's list names people rather than UUIDs.
|
||||
users, err := ListUsers(instanceID)
|
||||
if err == nil {
|
||||
byID := make(map[string]string, len(users))
|
||||
for _, u := range users {
|
||||
byID[u.UserID] = u.Email
|
||||
}
|
||||
for i := range tokens {
|
||||
tokens[i].UserEmail = byID[tokens[i].UserID]
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// RevokeAPIToken deletes a token. A member may revoke only their own; owner and
|
||||
// admin may revoke any token in the instance.
|
||||
func RevokeAPIToken(instanceID, tokenID string, requester *models.User) (*models.APIToken, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tok models.APIToken
|
||||
err := db.Col("api_tokens").FindOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}).Decode(&tok)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
elevated := requester.Role == models.RoleOwner || requester.Role == models.RoleAdmin
|
||||
if tok.UserID != requester.UserID && !elevated {
|
||||
// Not 403: confirming the token exists tells a member about somebody
|
||||
// else's credential. Same argument as admin's customer endpoints.
|
||||
return nil, ErrTokenNotFound
|
||||
}
|
||||
|
||||
if _, err := db.Col("api_tokens").DeleteOne(ctx, bson.M{"instance_id": instanceID, "token_id": tokenID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// DeleteTokensForUser removes every token belonging to a user. Offboarding is
|
||||
// one action, not two: a token that outlives its owner is an access path with
|
||||
// nobody attached to it.
|
||||
func DeleteTokensForUser(instanceID, userID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := db.Col("api_tokens").DeleteMany(ctx, bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
return err
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -175,5 +176,14 @@ func DeleteUser(instanceID, userID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Offboarding is one action. A token outliving its owner is an access path
|
||||
// with nobody attached to it.
|
||||
if err := DeleteTokensForUser(instanceID, userID); err != nil {
|
||||
log.Printf("delete tokens for user %s: %v", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,18 @@ type Settings struct {
|
||||
// WorkflowLogRetentionDays is: absent must mean the default, not zero.
|
||||
// Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept.
|
||||
VulnFindingRetentionDays *int `bson:"vuln_finding_retention_days,omitempty" json:"vuln_finding_retention_days,omitempty"`
|
||||
|
||||
// APITokenMaxDays caps how long a newly created API token may live.
|
||||
//
|
||||
// A pointer for the same reason the retention fields are: absent must mean
|
||||
// the default, and the default here is no cap at all — never-expire tokens
|
||||
// are allowed until an instance decides otherwise, so an upgrade changes
|
||||
// nothing. Nil or 0 is no cap. A positive value refuses both a longer
|
||||
// expiry and a token with no expiry.
|
||||
//
|
||||
// It is a policy on issuance, not on use: raising or lowering it never
|
||||
// invalidates a token that already exists.
|
||||
APITokenMaxDays *int `bson:"api_token_max_days,omitempty" json:"api_token_max_days,omitempty"`
|
||||
}
|
||||
|
||||
// LocalLoginEnabled reads the setting with its absent-means-on default. Every
|
||||
@@ -49,3 +61,13 @@ func LocalLoginEnabled(s *Settings) bool {
|
||||
}
|
||||
return *s.LocalLoginEnabled
|
||||
}
|
||||
|
||||
// APITokenMaxDays reads the token lifetime cap with its absent-means-uncapped
|
||||
// default. 0 means no cap. Every caller must go through this rather than
|
||||
// dereferencing the field.
|
||||
func APITokenMaxDays(s *Settings) int {
|
||||
if s == nil || s.APITokenMaxDays == nil || *s.APITokenMaxDays < 0 {
|
||||
return 0
|
||||
}
|
||||
return *s.APITokenMaxDays
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Field } from "@/components/settings/Field";
|
||||
import { Group } from "@/components/settings/Group";
|
||||
import { SectionCard } from "@/components/settings/SectionCard";
|
||||
import { MembersCard } from "@/components/settings/MembersCard";
|
||||
import { ApiTokensCard } from "@/components/settings/ApiTokensCard";
|
||||
import { AuthProvidersCard } from "@/components/settings/AuthProvidersCard";
|
||||
|
||||
const numberInputClass =
|
||||
@@ -144,6 +145,7 @@ export default function SettingsPage() {
|
||||
const [thresholdMinutes, setThresholdMinutes] = useState(5);
|
||||
const [logRetentionDays, setLogRetentionDays] = useState(30);
|
||||
const [offlineChannelIds, setOfflineChannelIds] = useState<string[]>([]);
|
||||
const [apiTokenMaxDays, setApiTokenMaxDays] = useState(0);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -151,6 +153,7 @@ export default function SettingsPage() {
|
||||
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
|
||||
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
|
||||
setOfflineChannelIds(settings.alerts.offline_channel_ids ?? []);
|
||||
setApiTokenMaxDays(settings.api_token_max_days ?? 0);
|
||||
}, [settings]);
|
||||
|
||||
// The one place the in-progress form is turned into a payload. Both the
|
||||
@@ -163,6 +166,7 @@ export default function SettingsPage() {
|
||||
offline_channel_ids: offlineChannelIds,
|
||||
},
|
||||
workflow_log_retention_days: logRetentionDays,
|
||||
api_token_max_days: apiTokenMaxDays,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +224,7 @@ export default function SettingsPage() {
|
||||
<div className="space-y-10">
|
||||
<Group label="Access">
|
||||
<MembersCard />
|
||||
<ApiTokensCard />
|
||||
<AuthProvidersCard
|
||||
localLoginEnabled={settings?.local_login_enabled ?? true}
|
||||
onLocalLoginChange={(v) => {
|
||||
@@ -291,6 +296,15 @@ export default function SettingsPage() {
|
||||
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
|
||||
<input type="number" min={0} value={logRetentionDays} onChange={(e) => setLogRetentionDays(Number(e.target.value))} className={numberInputClass} />
|
||||
</Field>
|
||||
|
||||
<div className="mt-6">
|
||||
<Field
|
||||
label="Maximum API token lifetime (days)"
|
||||
hint="0 means no cap, and tokens may be created with no expiry. Changing this affects new tokens only."
|
||||
>
|
||||
<input type="number" min={0} value={apiTokenMaxDays} onChange={(e) => setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} />
|
||||
</Field>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type ApiToken, type Role } from "@/lib/api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { Field, inputClass } from "./Field";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
const ROLES: Role[] = ["owner", "admin", "member"];
|
||||
|
||||
const EXPIRY_OPTIONS: { label: string; days: number | null }[] = [
|
||||
{ label: "30 days", days: 30 },
|
||||
{ label: "60 days", days: 60 },
|
||||
{ label: "90 days", days: 90 },
|
||||
{ label: "365 days", days: 365 },
|
||||
{ label: "Never", days: null },
|
||||
];
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** The token a pending revoke refers to, carried so the dialog and the
|
||||
* confirmation message name a token rather than a token_id. */
|
||||
type PendingRevoke = { id: string; name: string };
|
||||
|
||||
function TokenIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function roleVariant(role: Role) {
|
||||
if (role === "owner") return "accent" as const;
|
||||
if (role === "admin") return "warning" as const;
|
||||
return "neutral" as const;
|
||||
}
|
||||
|
||||
function rolesAtOrBelow(role: Role): Role[] {
|
||||
const idx = ROLES.indexOf(role);
|
||||
return idx === -1 ? ROLES : ROLES.slice(idx);
|
||||
}
|
||||
|
||||
/** Renders a token's expiry, plus a policy note when the cap has tightened
|
||||
* since the token was issued. The policy is not applied retroactively, so an
|
||||
* outside-policy token is a prompt to rotate, not a failure of any kind. */
|
||||
function ExpiryCell({ token, capDays }: { token: ApiToken; capDays: number }) {
|
||||
const outsidePolicy = capDays > 0 && (!token.expires_at || new Date(token.expires_at).getTime() > Date.now() + capDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (!token.expires_at) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-text-secondary">— never</span>
|
||||
{outsidePolicy && <p className="mt-0.5 text-xs text-warning">outside the current policy — rotate when convenient</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(token.expires_at);
|
||||
const expired = expiresAt.getTime() <= Date.now();
|
||||
const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className={expired ? "text-danger" : soon ? "text-warning" : "text-text-secondary"}>
|
||||
{expired ? `Expired ${expiresAt.toLocaleDateString()}` : expiresAt.toLocaleDateString()}
|
||||
</span>
|
||||
{outsidePolicy && <p className="mt-0.5 text-xs text-warning">outside the current policy — rotate when convenient</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiTokensCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, isAdmin } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [revoking, setRevoking] = useState<PendingRevoke | null>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<Role>("member");
|
||||
const [scopes, setScopes] = useState<string[]>([]);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(30);
|
||||
const [result, setResult] = useState<{ token: string; record: ApiToken } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings, enabled: isAdmin });
|
||||
const capDays = settings?.api_token_max_days ?? 0;
|
||||
|
||||
const {
|
||||
data: tokensData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({ queryKey: ["api-tokens", showAll], queryFn: () => api.listApiTokens(showAll) });
|
||||
const tokens = tokensData?.tokens;
|
||||
|
||||
const { data: scopesData } = useQuery({ queryKey: ["token-scopes"], queryFn: api.listTokenScopes, enabled: createOpen });
|
||||
const availableScopes = scopesData?.scopes ?? [];
|
||||
const resources = Array.from(new Set(availableScopes.map((s) => s.split(":")[0])));
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
|
||||
|
||||
function resetForm() {
|
||||
setName("");
|
||||
setRole("member");
|
||||
setScopes([]);
|
||||
setExpiryDays(30);
|
||||
setResult(null);
|
||||
setCopied(false);
|
||||
}
|
||||
|
||||
// Once the policy is known, default to the shortest option the policy still
|
||||
// allows rather than a value the submit is about to be refused for.
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
const valid = EXPIRY_OPTIONS.find((o) => !(capDays > 0 && (o.days === null || o.days > capDays)));
|
||||
if (valid) setExpiryDays(valid.days);
|
||||
}, [createOpen, capDays]);
|
||||
|
||||
const {
|
||||
mutate: createToken,
|
||||
isPending: creating,
|
||||
error: createError,
|
||||
reset: resetCreateError,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.createApiToken({ name, role, scopes, expires_in_days: expiryDays ?? undefined }),
|
||||
onSuccess: (res) => {
|
||||
setResult(res);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: revokeToken,
|
||||
isPending: isRevoking,
|
||||
error: revokeError,
|
||||
reset: resetRevoke,
|
||||
} = useMutation({
|
||||
mutationFn: (t: PendingRevoke) => api.revokeApiToken(t.id),
|
||||
onSuccess: (_data, t) => {
|
||||
invalidate();
|
||||
toast.success(`Revoked ${t.name}.`);
|
||||
setRevoking(null);
|
||||
},
|
||||
});
|
||||
|
||||
function toggleScope(s: string) {
|
||||
setScopes((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
// The plaintext is gone once this closes, so only invalidate having
|
||||
// shown it — closing before a result exists is a plain cancel.
|
||||
if (result) invalidate();
|
||||
setCreateOpen(false);
|
||||
resetCreateError();
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!result) return;
|
||||
await navigator.clipboard.writeText(result.token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="API tokens"
|
||||
description="Scoped, personal tokens for scripts and CI to call the REST API without a browser session."
|
||||
icon={<TokenIcon />}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAll}
|
||||
onChange={(e) => setShowAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
|
||||
/>
|
||||
All tokens
|
||||
</label>
|
||||
)}
|
||||
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
Create token
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="py-6 text-sm text-danger">{friendlyMessage(error)}</p>
|
||||
) : !tokens || tokens.length === 0 ? (
|
||||
<p className="py-6 text-sm text-text-secondary">No API tokens yet.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
{showAll && <Th>Owner</Th>}
|
||||
<Th>Role</Th>
|
||||
<Th>Scopes</Th>
|
||||
<Th>Last used</Th>
|
||||
<Th>Expires</Th>
|
||||
<Th className="text-right">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{tokens.map((t) => (
|
||||
<Tr key={t.token_id}>
|
||||
<Td label="Name">
|
||||
<span className="font-medium text-text-primary">{t.name}</span>
|
||||
<div className="font-mono text-xs text-text-secondary">{t.hint}…</div>
|
||||
</Td>
|
||||
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
|
||||
<Td label="Role">
|
||||
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
|
||||
</Td>
|
||||
<Td label="Scopes">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.scopes.map((s) => (
|
||||
<Badge key={s} variant="neutral">
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Td>
|
||||
<Td label="Last used" className="text-text-secondary">
|
||||
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"}
|
||||
</Td>
|
||||
<Td label="Expires">
|
||||
<ExpiryCell token={t} capDays={capDays} />
|
||||
</Td>
|
||||
<Td label="Actions" className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-danger hover:text-danger"
|
||||
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
|
||||
>
|
||||
Revoke<span className="sr-only"> {t.name}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={revoking !== null}
|
||||
title="Revoke token"
|
||||
confirmLabel="Revoke token"
|
||||
loading={isRevoking}
|
||||
error={revokeError ? friendlyMessage(revokeError) : null}
|
||||
onClose={() => {
|
||||
resetRevoke();
|
||||
setRevoking(null);
|
||||
}}
|
||||
onConfirm={() => revoking && revokeToken(revoking)}
|
||||
body={
|
||||
<p>
|
||||
<span className="text-text-primary">{revoking?.name}</span> stops authenticating immediately. Any script or CI job using it
|
||||
will start failing on its next call.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal open={createOpen} title={result ? "Token created" : "Create API token"} onClose={closeCreate}>
|
||||
{result ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">This is the only time this token will be shown. Store it now.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={copyToken}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={closeCreate}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createToken();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field label="Name" hint="A short label identifying what will use this token, e.g. the CI pipeline or the script.">
|
||||
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
|
||||
</Field>
|
||||
|
||||
<Field label="Role">
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
|
||||
{assignableRoles.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Scopes" hint="What this token may call. Grant only what the caller actually needs.">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{resources.map((r) => {
|
||||
const readScope = `${r}:read`;
|
||||
const writeScope = `${r}:write`;
|
||||
return (
|
||||
<div key={r} className="flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3 py-2">
|
||||
<span className="text-sm capitalize text-text-primary">{r}</span>
|
||||
<div className="flex gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopes.includes(readScope)}
|
||||
onChange={() => toggleScope(readScope)}
|
||||
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
|
||||
/>
|
||||
read
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopes.includes(writeScope)}
|
||||
onChange={() => toggleScope(writeScope)}
|
||||
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
|
||||
/>
|
||||
write
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Expires"
|
||||
hint={capDays > 0 ? `This instance caps new tokens at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the token has no expiry."}
|
||||
>
|
||||
<select
|
||||
value={expiryDays === null ? "never" : String(expiryDays)}
|
||||
onChange={(e) => setExpiryDays(e.target.value === "never" ? null : Number(e.target.value))}
|
||||
className={inputClass}
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((o) => {
|
||||
const disabled = capDays > 0 && (o.days === null || o.days > capDays);
|
||||
return (
|
||||
<option key={o.label} value={o.days === null ? "never" : String(o.days)} disabled={disabled}>
|
||||
{o.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{createError && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{friendlyMessage(createError)}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={closeCreate}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={creating}>
|
||||
Create token
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
+39
-1
@@ -197,8 +197,22 @@ export interface Settings {
|
||||
secrets: SecretsSettings;
|
||||
workflow_log_retention_days?: number | null;
|
||||
local_login_enabled?: boolean;
|
||||
api_token_max_days?: number | null;
|
||||
}
|
||||
|
||||
export type ApiToken = {
|
||||
token_id: string;
|
||||
name: string;
|
||||
hint: string;
|
||||
role: Role;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
created_at: string;
|
||||
last_used_at?: string | null;
|
||||
user_id: string;
|
||||
user_email?: string;
|
||||
};
|
||||
|
||||
export interface SecretGroupSummary {
|
||||
group: string;
|
||||
key_count: number;
|
||||
@@ -683,7 +697,12 @@ export const api = {
|
||||
return request<Settings>("/settings");
|
||||
},
|
||||
|
||||
saveSettings(settings: { alerts: AlertSettings; workflow_log_retention_days?: number | null; local_login_enabled?: boolean }): Promise<{ saved: boolean }> {
|
||||
saveSettings(settings: {
|
||||
alerts: AlertSettings;
|
||||
workflow_log_retention_days?: number | null;
|
||||
local_login_enabled?: boolean;
|
||||
api_token_max_days?: number | null;
|
||||
}): Promise<{ saved: boolean }> {
|
||||
return request<{ saved: boolean }>("/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
@@ -694,6 +713,25 @@ export const api = {
|
||||
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
|
||||
},
|
||||
|
||||
listApiTokens(all = false): Promise<{ tokens: ApiToken[]; all: boolean }> {
|
||||
return request<{ tokens: ApiToken[]; all: boolean }>(`/tokens${all ? "?all=true" : ""}`);
|
||||
},
|
||||
|
||||
listTokenScopes(): Promise<{ scopes: string[] }> {
|
||||
return request<{ scopes: string[] }>("/tokens/scopes");
|
||||
},
|
||||
|
||||
createApiToken(body: { name: string; role: Role; scopes: string[]; expires_in_days?: number | null }): Promise<{ token: string; record: ApiToken }> {
|
||||
return request<{ token: string; record: ApiToken }>("/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
revokeApiToken(tokenId: string): Promise<{ revoked: boolean }> {
|
||||
return request<{ revoked: boolean }>(`/tokens/${tokenId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
listSecretGroups(): Promise<SecretGroupSummary[]> {
|
||||
return request<SecretGroupSummary[]>("/secrets");
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ export const AUDIT_CATEGORIES: { value: string; label: string }[] = [
|
||||
{ value: "updates", label: "OS updates" },
|
||||
{ value: "auth_provider", label: "Single sign-on" },
|
||||
{ value: "settings", label: "Settings" },
|
||||
{ value: "token", label: "API tokens" },
|
||||
{ value: "license", label: "Licence" },
|
||||
{ value: "instance", label: "Instance" },
|
||||
];
|
||||
@@ -98,6 +99,10 @@ const OVERRIDES: Record<string, string> = {
|
||||
"instance.reaped": "Instance deleted",
|
||||
"vuln.rescan": "Rescan requested",
|
||||
"workload.logs_read": "Workload logs read",
|
||||
"token.created": "API token created",
|
||||
"token.revoked": "API token revoked",
|
||||
"token.expired_use": "Expired API token used",
|
||||
"settings.token_policy_updated": "API token policy updated",
|
||||
};
|
||||
|
||||
export interface AuditEventDisplay {
|
||||
|
||||
Reference in New Issue
Block a user