Harden production workflows and agent admission

This commit is contained in:
Stanislav Rossovskii
2026-05-28 14:19:27 +04:00
parent a2e88b4e76
commit 37b1a1d6d6
109 changed files with 4927 additions and 894 deletions

View File

@@ -5,11 +5,16 @@ info:
description: |
Monlet v1 API contract. Stage 1 frozen.
All endpoints under `/api/v1`. Authentication uses a single shared Bearer token (see ADR-0007). Agent-facing ingestion endpoints (`/heartbeat`, `/events`) and read-only UI-facing endpoints share the same security scheme in v1.
All endpoints under `/api/v1`. Authentication is split (ADR-0007):
- `agentToken` — self-generated agent Bearer key stored locally by the agent. Used by ingestion paths `POST /api/v1/heartbeat` and `POST /api/v1/events`.
- `uiToken` — operator/UI shared Bearer token from `MONLET_AUTH_TOKEN`. Used by every other authenticated endpoint. Supports overlap rotation via space/comma-separated list (PH-008).
Both are presented as `Authorization: Bearer <token>`. UI token comparison is constant-time. Agent keys are stored server-side only as hashes/fingerprints and never appear in logs, metrics, or notifications.
servers:
- url: http://127.0.0.1:8000
security:
- bearerAuth: []
- uiToken: []
paths:
/api/v1/health:
get:
@@ -45,6 +50,8 @@ paths:
post:
summary: Ingest agent heartbeat
operationId: ingestHeartbeat
security:
- agentToken: []
requestBody:
required: true
content:
@@ -62,14 +69,26 @@ paths:
$ref: "#/components/responses/ValidationError"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
description: Agent key is blocked.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
$ref: "#/components/responses/Conflict"
"413":
$ref: "#/components/responses/PayloadTooLarge"
"429":
$ref: "#/components/responses/RateLimited"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/events:
post:
summary: Ingest agent check result events
operationId: ingestEvents
security:
- agentToken: []
requestBody:
required: true
content:
@@ -87,6 +106,12 @@ paths:
$ref: "#/components/responses/ValidationError"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
description: Agent key is blocked.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"413":
$ref: "#/components/responses/PayloadTooLarge"
"500":
@@ -97,6 +122,7 @@ paths:
operationId: listAgents
parameters:
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Back"
- $ref: "#/components/parameters/Limit"
responses:
"200":
@@ -115,6 +141,109 @@ paths:
$ref: "#/components/schemas/Agent"
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/agents/admission/blacklist:
get:
summary: List blocked agent keys
operationId: listAgentBlacklist
parameters:
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Agent blacklist.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PageEnvelope"
- type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/AgentBlacklistItem"
"401":
$ref: "#/components/responses/Unauthorized"
delete:
summary: Clear blocked agent keys
operationId: clearAgentBlacklist
responses:
"200":
description: Number of removed blacklist rows.
content:
application/json:
schema:
$ref: "#/components/schemas/ActionCountResponse"
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/agents/admission/blacklist/{key_id}:
delete:
summary: Remove one blocked agent key
operationId: deleteAgentBlacklistItem
parameters:
- name: key_id
in: path
required: true
schema:
type: string
minLength: 64
maxLength: 64
responses:
"200":
description: Number of removed blacklist rows.
content:
application/json:
schema:
$ref: "#/components/schemas/ActionCountResponse"
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/agents/admission/accept-all:
post:
summary: Accept all pending agents
operationId: acceptAllPendingAgents
parameters:
- name: include_rotation
in: query
schema:
type: boolean
default: false
description: When false, accepted agents with pending replacement keys are skipped.
responses:
"200":
description: Number of accepted agents.
content:
application/json:
schema:
$ref: "#/components/schemas/ActionCountResponse"
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/agents/admission/block-all:
post:
summary: Block all pending agents
operationId: blockAllPendingAgents
responses:
"200":
description: Number of blocked agent keys.
content:
application/json:
schema:
$ref: "#/components/schemas/ActionCountResponse"
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/agents/admission/remove-all:
post:
summary: Remove all pending agents
operationId: removeAllPendingAgents
responses:
"200":
description: Number of removed agents.
content:
application/json:
schema:
$ref: "#/components/schemas/ActionCountResponse"
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/agents/{agent_id}:
get:
summary: Get agent detail
@@ -132,6 +261,56 @@ paths:
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
delete:
summary: Remove agent and all owned state
operationId: deleteAgent
parameters:
- $ref: "#/components/parameters/AgentId"
responses:
"200":
description: Number of removed agents.
content:
application/json:
schema:
$ref: "#/components/schemas/ActionCountResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
/api/v1/agents/{agent_id}/accept:
post:
summary: Accept pending agent key
operationId: acceptAgent
parameters:
- $ref: "#/components/parameters/AgentId"
responses:
"200":
description: Number of accepted agents.
content:
application/json:
schema:
$ref: "#/components/schemas/ActionCountResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
/api/v1/agents/{agent_id}/block:
post:
summary: Block pending agent key
operationId: blockAgent
parameters:
- $ref: "#/components/parameters/AgentId"
responses:
"200":
description: Number of blocked agent keys.
content:
application/json:
schema:
$ref: "#/components/schemas/ActionCountResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
/api/v1/checks:
get:
summary: List current check states
@@ -145,6 +324,7 @@ paths:
maxLength: 128
pattern: "^[A-Za-z0-9._:-]+$"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Back"
- $ref: "#/components/parameters/Limit"
responses:
"200":
@@ -174,7 +354,37 @@ paths:
schema:
type: string
enum: [open, resolved]
- name: severity
in: query
schema:
type: string
enum: [warning, critical, unknown]
- name: agent_id
in: query
schema:
type: string
maxLength: 128
pattern: "^[A-Za-z0-9._:-]+$"
- name: check_id
in: query
schema:
type: string
maxLength: 128
pattern: "^[A-Za-z0-9._:-]+$"
- name: sort
in: query
schema:
type: string
enum: [status, opened_at, resolved_at]
default: status
- name: direction
in: query
schema:
type: string
enum: [asc, desc]
default: desc
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Back"
- $ref: "#/components/parameters/Limit"
responses:
"200":
@@ -213,6 +423,7 @@ paths:
maxLength: 128
pattern: "^[A-Za-z0-9._:-]+$"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Back"
- $ref: "#/components/parameters/Limit"
responses:
"200":
@@ -231,12 +442,56 @@ paths:
$ref: "#/components/schemas/StoredCheckResultEvent"
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/system-summary:
get:
summary: System navigation summary
operationId: getSystemSummary
description: |
Bounded aggregate counts for the top navigation. Safe to poll from many
open dashboards because response size is independent of row counts.
responses:
"200":
description: System summary snapshot.
content:
application/json:
schema:
$ref: "#/components/schemas/SystemSummaryResponse"
"401":
$ref: "#/components/responses/Unauthorized"
/api/v1/notifiers/outbox:
get:
summary: List notification outbox items
operationId: listOutbox
parameters:
- name: state
in: query
schema:
type: string
enum: [pending, sending, sent, retry, failed, discarded]
- name: notifier
in: query
schema:
type: string
enum: [debug, telegram, webhook, alertmanager]
- name: event_type
in: query
schema:
type: string
enum: [firing, resolved]
- name: sort
in: query
schema:
type: string
enum: [created_at, updated_at]
default: updated_at
- name: direction
in: query
schema:
type: string
enum: [asc, desc]
default: desc
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Back"
- $ref: "#/components/parameters/Limit"
responses:
"200":
@@ -257,10 +512,21 @@ paths:
$ref: "#/components/responses/Unauthorized"
components:
securitySchemes:
bearerAuth:
agentToken:
type: http
scheme: bearer
description: Shared static Bearer token in v1. See ADR-0007.
description: |
Self-generated agent Bearer key. The agent creates and persists it in
local state; the server stores only hash/fingerprint and requires UI
acceptance before events affect state. Used only by `/heartbeat` and
`/events`. See ADR-0007.
uiToken:
type: http
scheme: bearer
description: |
Operator/UI Bearer token from `MONLET_AUTH_TOKEN`. Multiple tokens may
be active simultaneously (space/comma separated) for zero-loss rotation.
See PH-008 and ADR-0007.
parameters:
AgentId:
name: agent_id
@@ -274,10 +540,18 @@ components:
name: cursor
in: query
required: false
description: Opaque pagination cursor returned in `next_cursor`.
description: Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`).
schema:
type: string
maxLength: 512
Back:
name: back
in: query
required: false
description: When true, treat the `cursor` as a `prev_cursor` and return the previous page.
schema:
type: boolean
default: false
Limit:
name: limit
in: query
@@ -300,12 +574,24 @@ components:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
Conflict:
description: Request conflicts with current server state.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
PayloadTooLarge:
description: Payload exceeds configured limit (1 MiB).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
RateLimited:
description: Request rejected by configured rate/volume limits.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
ValidationError:
description: Request body or parameters failed validation.
content:
@@ -338,6 +624,9 @@ components:
type: string
enum:
- unauthorized
- forbidden
- conflict
- agent_blocked
- not_found
- validation
- payload_too_large
@@ -355,6 +644,9 @@ components:
next_cursor:
type: [string, "null"]
description: Pass back as `cursor` to fetch the next page. Null/absent if no more results.
prev_cursor:
type: [string, "null"]
description: Pass back as `cursor` with `back=true` to fetch the previous page. Null on the first page.
AcceptedResponse:
type: object
required: [accepted]
@@ -438,7 +730,7 @@ components:
description: |
Event body used inside `EventBatchRequest.events`. The owning `agent_id`
is supplied once at the batch envelope; per-event `agent_id` is intentionally
absent so requests cannot mix or spoof other agents under a shared token.
absent so one HTTP request cannot mix multiple agents.
Server response endpoints (`GET /events/query`) include `agent_id` separately
in their item schema.
required:
@@ -510,7 +802,7 @@ components:
format: date-time
Agent:
type: object
required: [agent_id, hostname, status, last_seen_at, features]
required: [agent_id, hostname, status, admission_state, last_seen_at, features]
properties:
agent_id:
type: string
@@ -519,6 +811,17 @@ components:
status:
type: string
enum: [alive, stale, dead]
admission_state:
type: string
enum: [pending, accepted]
rotation_pending:
type: boolean
default: false
description: True when a pending key would replace an already accepted key for this agent.
key_fingerprint:
type: [string, "null"]
minLength: 16
maxLength: 16
last_seen_at:
type: string
format: date-time
@@ -528,6 +831,36 @@ components:
type: object
additionalProperties:
type: string
AgentBlacklistItem:
type: object
required: [id, key_fingerprint, agent_id, hostname, created_at, last_seen_at]
properties:
id:
type: string
minLength: 64
maxLength: 64
description: Opaque blacklist row id. Use for delete operations; display `key_fingerprint` to humans.
key_fingerprint:
type: string
minLength: 16
maxLength: 16
agent_id:
type: string
hostname:
type: string
created_at:
type: string
format: date-time
last_seen_at:
type: string
format: date-time
ActionCountResponse:
type: object
required: [count]
properties:
count:
type: integer
minimum: 0
CheckState:
type: object
required: [agent_id, check_id, status, last_observed_at]
@@ -602,3 +935,40 @@ components:
updated_at:
type: string
format: date-time
SystemSummaryResponse:
type: object
description: Bounded aggregate counts for the web navigation.
required:
- agents_online
- agents_offline
- checks_ok
- checks_warning
- checks_critical
- checks_unknown
- open_incidents
- generated_at
properties:
agents_online:
type: integer
minimum: 0
agents_offline:
type: integer
minimum: 0
checks_ok:
type: integer
minimum: 0
checks_warning:
type: integer
minimum: 0
checks_critical:
type: integer
minimum: 0
checks_unknown:
type: integer
minimum: 0
open_incidents:
type: integer
minimum: 0
generated_at:
type: string
format: date-time