diff --git a/api/openapi.yaml b/api/openapi.yaml index 360ccc6b..e1ca1175 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1667,6 +1667,330 @@ paths: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} + # =========================================================================== + # Remediation (Phase 7). Free core: request -> approve | reject + projected + # lift (see-and-govern). The act verbs (:dry-run, :execute, :rollback) are + # OpenWatch+ licensed (remediation_execution) and return 402 on the free + # tier. Spec api-remediation; plan docs/engineering/remediation_core_plan.md. + # =========================================================================== + /api/v1/remediation/requests: + get: + operationId: listRemediationRequests + summary: List remediation requests (fleet or filtered) + description: | + Remediation request queue, newest first, optionally filtered by + status, host_id, or rule_id; capped at limit (default 200). RBAC: + remediation:read. Spec api-remediation. + x-required-permission: remediation:read + parameters: + - name: status + in: query + required: false + schema: + type: string + enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed] + - name: host_id + in: query + required: false + schema: {type: string, format: uuid} + - name: rule_id + in: query + required: false + schema: {type: string} + - name: limit + in: query + required: false + schema: {type: integer, default: 200} + responses: + '200': + description: Remediation requests, newest first + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationRequestList'} + '403': + description: Caller lacks remediation:read permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + post: + operationId: requestRemediation + summary: Request a remediation (fix) for a failing rule on a host + description: | + Submits a pending_approval remediation request for a host+rule and + records the projected per-framework compliance lift. One open request + is allowed per host+rule; a duplicate returns 409. The requester is + the authenticated user. This NEVER contacts the host. RBAC: + remediation:request. Spec api-remediation. + x-required-permission: remediation:request + x-audit-events: [remediation.requested] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationRequestCreate'} + responses: + '201': + description: The created remediation request (pending approval) + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationRequest'} + '400': {$ref: '#/components/responses/BadRequest'} + '404': + description: Unknown or deleted host + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '403': + description: Caller lacks remediation:request permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '409': + description: An open remediation request already exists for this host and rule + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}: + get: + operationId: getRemediationRequest + summary: Get a remediation request + description: 'RBAC: remediation:read. Spec api-remediation.' + x-required-permission: remediation:read + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + responses: + '200': + description: The remediation request + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationRequest'} + '404': + description: Remediation request not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '403': + description: Caller lacks remediation:read permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}/steps: + get: + operationId: listRemediationSteps + summary: List a remediation request's transaction journal (steps) + description: | + Per-step Kensa transaction journal (Capture/Apply/Validate/Commit + outcomes). Empty until the request is executed (executing is the + OpenWatch+ licensed track). RBAC: remediation:read. Spec + api-remediation. + x-required-permission: remediation:read + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + responses: + '200': + description: Steps, in apply order + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationStepList'} + '404': + description: Remediation request not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '403': + description: Caller lacks remediation:read permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}:approve: + post: + operationId: approveRemediation + summary: Approve a pending remediation request + description: | + pending_approval -> approved. The reviewer must differ from the + requester (separation of duties, 409). RBAC: remediation:approve. + Spec api-remediation. + x-required-permission: remediation:approve + x-audit-events: [remediation.approved] + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationReview'} + responses: + '200': + description: The approved remediation request + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationRequest'} + '403': + description: Caller lacks remediation:approve permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '404': + description: Remediation request not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '409': + description: Not in the pending_approval state, or reviewer is the requester + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}:reject: + post: + operationId: rejectRemediation + summary: Reject a pending remediation request + description: | + pending_approval -> rejected. The reviewer must differ from the + requester (409). RBAC: remediation:approve. Spec api-remediation. + x-required-permission: remediation:approve + x-audit-events: [remediation.approved] + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationReview'} + responses: + '200': + description: The rejected remediation request + content: + application/json: + schema: {$ref: '#/components/schemas/RemediationRequest'} + '403': + description: Caller lacks remediation:approve permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '404': + description: Remediation request not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '409': + description: Not in the pending_approval state, or reviewer is the requester + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}:dry-run: + post: + operationId: dryRunRemediation + summary: Dry-run a remediation (OpenWatch+) + description: | + Connects to the host and reports what would change without applying. + License-gated: requires remediation:execute AND the + remediation_execution feature; returns 402 on the free tier. The + execution body is the OpenWatch+ licensed track. Spec api-remediation. + x-required-permission: remediation:execute + x-required-feature: remediation_execution + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + responses: + '402': + description: License does not include remediation_execution + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '403': + description: Caller lacks remediation:execute permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '501': + description: Licensed execution body not yet implemented (OpenWatch+ track) + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}:execute: + post: + operationId: executeRemediation + summary: Execute a remediation against the host (OpenWatch+) + description: | + Applies the fix (Capture/Apply/Validate/Commit). License-gated: + requires remediation:execute AND remediation_execution; returns 402 on + the free tier. The execution body is the OpenWatch+ licensed track. + Spec api-remediation. + x-required-permission: remediation:execute + x-required-feature: remediation_execution + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + responses: + '402': + description: License does not include remediation_execution + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '403': + description: Caller lacks remediation:execute permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '501': + description: Licensed execution body not yet implemented (OpenWatch+ track) + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}:rollback: + post: + operationId: rollbackRemediation + summary: Roll back an executed remediation (OpenWatch+) + description: | + Restores the captured pre-state. License-gated: requires + remediation:rollback AND remediation_execution; returns 402 on the + free tier. The execution body is the OpenWatch+ licensed track. Spec + api-remediation. + x-required-permission: remediation:rollback + x-required-feature: remediation_execution + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + responses: + '402': + description: License does not include remediation_execution + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '403': + description: Caller lacks remediation:rollback permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '501': + description: Licensed execution body not yet implemented (OpenWatch+ track) + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + /api/v1/groups: get: operationId: getGroups @@ -4499,6 +4823,90 @@ components: properties: note: {type: string, description: Optional reviewer note} + ProjectedLift: + type: object + description: > + Estimated per-framework compliance-score delta (percentage points) if + the rule flips to pass. Best-effort; a field is null when that + framework's data is unavailable for the host. + properties: + cis: {type: number, format: double, nullable: true} + stig: {type: number, format: double, nullable: true} + nist: {type: number, format: double, nullable: true} + + RemediationRequest: + type: object + required: [id, host_id, rule_id, status, requested_by, requested_at] + properties: + id: {type: string, format: uuid} + host_id: {type: string, format: uuid} + host_name: + type: string + description: Hostname, populated on list responses (empty on single-row lifecycle results) + rule_id: {type: string} + status: + type: string + enum: [pending_approval, approved, rejected, dry_run_complete, executing, executed, rolled_back, failed] + requested_by: {type: string, format: uuid} + reviewed_by: {type: string, format: uuid, nullable: true} + review_note: {type: string} + scan_run_id: {type: string, format: uuid, nullable: true} + mechanism: + type: string + description: Kensa remediation handler id (empty when unknown at request time) + reboot_required: {type: boolean} + transactional: {type: boolean} + projected_lift: {$ref: '#/components/schemas/ProjectedLift'} + requested_at: {type: string, format: date-time} + reviewed_at: {type: string, format: date-time, nullable: true} + + RemediationRequestList: + type: object + required: [requests] + properties: + requests: + type: array + items: {$ref: '#/components/schemas/RemediationRequest'} + + RemediationRequestCreate: + type: object + required: [host_id, rule_id] + properties: + host_id: {type: string, format: uuid} + rule_id: {type: string} + scan_run_id: + type: string + format: uuid + nullable: true + description: Optional provenance - the scan run whose finding this remediates + + RemediationReview: + type: object + properties: + note: {type: string, description: Optional reviewer note} + + RemediationStep: + type: object + required: [id, rule_id, dry_run] + properties: + id: {type: string, format: uuid} + rule_id: {type: string} + mechanism: {type: string} + phase_result: + type: string + nullable: true + enum: [committed, rolled_back, skipped] + dry_run: {type: boolean} + applied_at: {type: string, format: date-time, nullable: true} + + RemediationStepList: + type: object + required: [steps] + properties: + steps: + type: array + items: {$ref: '#/components/schemas/RemediationStep'} + Group: type: object required: [id, name, kind, subtype, color, membership, maintenance, diff --git a/cmd/openwatch/main.go b/cmd/openwatch/main.go index dd02be69..2dc24276 100644 --- a/cmd/openwatch/main.go +++ b/cmd/openwatch/main.go @@ -50,6 +50,7 @@ import ( openlog "github.com/Hanalyx/openwatch/internal/log" "github.com/Hanalyx/openwatch/internal/notification" "github.com/Hanalyx/openwatch/internal/posture" + "github.com/Hanalyx/openwatch/internal/remediation" "github.com/Hanalyx/openwatch/internal/report" "github.com/Hanalyx/openwatch/internal/scanresult" compsched "github.com/Hanalyx/openwatch/internal/scheduler" @@ -573,6 +574,10 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int { exceptionSvc := exception.NewService(pool, audit.Emit) exceptionSvc.Run(ctx, 0) + // Remediation governance (free core: request/approve/reject + projected + // lift; the act verbs are OpenWatch+ licensed). Spec api-remediation. + remediationSvc := remediation.NewService(pool, audit.Emit) + scanWorker := worker.NewScanWorker(worker.Config{ Pool: pool, Executor: scanExecutor, @@ -596,6 +601,7 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int { WithRuleLibrary(ruleLibrary). WithVariableCatalog(varCatalog). WithExceptions(exceptionSvc). + WithRemediation(remediationSvc). WithGroups(group.NewService(pool)). WithReports(report.NewService(pool)). WithScanResults(scanresult.NewReader(pool)). diff --git a/docs/engineering/remediation_core_plan.md b/docs/engineering/remediation_core_plan.md new file mode 100644 index 00000000..8d969624 --- /dev/null +++ b/docs/engineering/remediation_core_plan.md @@ -0,0 +1,193 @@ +# Remediation — OpenWatch Core (Free) Plan + +> **Companion doc:** [`remediation_licensed_plan.md`](remediation_licensed_plan.md) +> covers the OpenWatch+ (paid) half. **Forward-looking remainder context:** +> [`scan_remaining_work.md`](scan_remaining_work.md) (Phase 7). +> +> **Status:** scoping / design. No remediation handler, service, or schema +> exists yet — only the registries (RBAC, license feature, audit codes) and the +> OpenAPI skeleton. This doc defines what ships **free, in the AGPLv3 core**. + +--- + +## 1. Why a free/paid line exists here at all + +OpenWatch Core is **AGPLv3 + Managed Service Exception** (`LICENSE`). The MSE +restricts *offering OpenWatch as a hosted service to third parties*; it does +**not** grant feature tiering. Feature tiering is a separate **open-core / +dual-licensing** decision layered on top of the AGPL base, enforced by the +license subsystem (`internal/license/`, `licensing/features.yaml`, signed +Ed25519 JWTs minted by `cmd/owlicgen`). + +The product line is **"OpenWatch sees, plans, and governs remediation for +free; the act of mutating a host is OpenWatch+."** This doc is the *free* side +of that line. The paid side is the companion doc. + +> **AGPL implication, stated plainly.** Any code that ships in this core tree is +> source you are obliged to publish (AGPLv3 §13) and that a user may legally +> modify, including deleting a runtime license check (§2). So an in-core 402 +> gate is an *honor-system + friction* control, not DRM. That is an acceptable +> and common open-core posture for the manual-execution tier; the robustly +> gated capability (the auto-remediation engine) is treated differently in the +> companion doc. See Decision D-3 there. + +--- + +## 2. The boundary (what is free) + +| Capability | Free (this doc) | Licensed (companion) | +|---|---|---| +| View remediable findings, projected score lift | ✅ | | +| Request a remediation (`remediation:request`) | ✅ | | +| Approve / reject a request (`remediation:approve`) | ✅ | | +| View transaction history + signed evidence (`remediation:read`) | ✅ | | +| Configure the approvals policy (who approves, dual-approval) | ✅ | | +| **Dry-run a fix** (`remediation:execute`) | | ✅ `remediation_execution` | +| **Execute a fix on a host** (`remediation:execute`) | | ✅ `remediation_execution` | +| **Rollback** (`remediation:rollback`) | | ✅ `remediation_execution` | +| Bulk / fleet / auto-remediation policy engine | | ✅ (proposed `remediation_auto`) | + +The free tier is a complete **see-and-govern** loop: an operator can discover +what is fixable, understand the projected compliance-score impact, request the +fix, route it through approval, and audit every fix that was applied. The one +thing it cannot do is pull the trigger on a host mutation — that is the paid +moment, and the upsell is honest because the whole workflow up to it is free. + +This matches OpenWatch's "The Eye" visibility-first positioning and the risk +gradient ratified in `scan_remaining_work.md` (read-only is safe; host mutation +has blast radius). + +--- + +## 3. What already exists (build on, do not re-create) + +- **RBAC** (`auth/permissions.yaml` → `internal/auth/permissions.gen.go`): + `remediation:read`, `:request`, `:approve` (free); `:execute`, `:rollback` + (`license_gated: remediation_execution`, `dangerous: true`). +- **Audit codes** (`audit/events.yaml`): `remediation.requested`, + `remediation.approved`, `remediation.executed`, `remediation.rolled_back`. +- **OpenAPI skeleton** (`api/remediation.yaml`, fidelity = skeleton): the full + lifecycle `request → approve → dry-run → execute → rollback`, with read + endpoints explicitly un-gated and act endpoints gated. Also + `api/scans.yaml` → `POST /scans/{scan_id}:remediate` (create-from-findings). +- **Kensa** (`internal/kensa/`, kensa **v0.5.0**): `executor.go` wired for + scans; `transport.go` implements `Run` (scan path), with `Put`/`Get` stubbed + (`ErrTransportOpNotSupported`) pending a remediation payload-upload need. The + Kensa transaction model is `Capture → Apply → Validate → Commit`, with + automatic pre-state restore on validation failure. +- **License subsystem** (`internal/license/`): `EnforcePermission` / + `EnforceFeature` / `RequireFeature`, 402-on-deny with rate-limited audit; + free tier with no license file; SIGHUP reload. + +**Does not exist:** any `remediations` migration (next number is **0037**), any +remediation handler/service, any frontend beyond the placeholder Remediation +tab (`HostDetailPage.tsx`, "deferred (BACKLOG)"). + +--- + +## 4. Architecture — what the core owns + +The data model and state machine are built **in core** because both the free +governance path and the paid execution path read and write the same tables. +Only the *act* handlers carry the license check. + +### 4.1 Schema (migration `0037_remediation.sql`) + +- `remediation_requests` — one row per requested fix. + `id`, `host_id`, `rule_id`, `scan_run_id` (provenance), + `status` (`pending_approval | approved | rejected | dry_run_complete | + executing | executed | rolled_back | failed`), `requester_id`, + `approver_id`, `created_at`, `decided_at`, projected-lift snapshot + (`projected_cis`, `projected_stig`, `projected_nist`), `mechanism` + (kensa handler id), `reboot_required bool`, `transactional bool`. +- `remediation_transactions` — the Kensa per-rule transaction journal: `id`, + `request_id`, `kensa_txn_id`, `phase_result` (`committed | rolled_back | + skipped`), `pre_state` (captured), `evidence` (content-addressed, mirrors the + `scan_results` store pattern), `applied_at`. This is the durable rollback + point and the signed-evidence record (`kensa verify`). + +State transitions only ever move forward except the `:rollback` path +(`executed → rolled_back`). The journal is append-only. + +### 4.2 Service (`internal/remediation/`) + +- `Request(...)`, `Approve(...)`, `Reject(...)` — free verbs; pure state + transitions + audit, no host contact. +- `ProjectLift(...)` — read-only: compute the predicted CIS/STIG/NIST delta if a + rule (or set) flips to pass, from the current `host_rule_state` + framework + mappings. Powers the "Projected lift" UI. No mutation. +- The mutating methods (`DryRun`, `Execute`, `Rollback`) are **defined in core** + but their handlers call `EnforceFeature(remediation_execution)` before + touching a host (see companion doc). The Kensa apply/rollback plumbing + (`transport.Put`/`Get` if a mechanism needs to push a payload) lands here. + +### 4.3 API (core-owned, free endpoints) + +From the existing `api/remediation.yaml` skeleton, promote to full fidelity the +un-gated endpoints: + +- `GET /api/v1/remediation/requests` (list, filter) +- `GET /api/v1/remediation/requests/{id}` (+ `/steps`, `/audit`) +- `POST /api/v1/remediation/requests` (`remediation:request`) +- `POST /api/v1/remediation/requests/{id}:approve` (`remediation:approve`) +- `POST /api/v1/remediation/requests/{id}:reject` +- `POST /api/v1/scans/{scan_id}:remediate` (create requests from findings) + +--- + +## 5. Frontend (free surfaces) + +- **Compliance tab → Top failed rules** (`HostDetailPage`): each failed rule + gets a **"Request remediation"** affordance (prototype shows "Remediate"; the + free action is *request*, which routes to approval). Shows the per-rule + projected lift. +- **Remediation tab** (read surfaces only in the free build): the "How each fix + runs · Capture → Apply → Validate → Commit" explainer, the + `committed/rolled_back/skipped` legend, the **Recent transactions** table with + signed-evidence verification, and per-request status. The **Remediate / + Rollback buttons render as upsell** (disabled with an "OpenWatch+" affordance) + when the license lacks `remediation_execution`. The frontend does not gate + today (backend-only enforcement); this adds the first license-aware UI. +- **Projected lift** display is free everywhere it appears (planning is free; + applying is paid). + +--- + +## 6. Specs to author (SDD) + +- `system-remediation` — the request/approve state machine, schema invariants, + audit emission, the free/paid verb split as a constraint. +- `api-remediation` — promote `api/remediation.yaml` from skeleton; ACs for the + free endpoints + the 402 contract on the act endpoints. +- `frontend-remediation-tab` — the read surfaces + the request affordance + the + license-upsell rendering. + +Register in `specter.yaml`; annotate tests with `// @spec` + `// @ac`. + +--- + +## 7. Sequencing + +1. Migration `0037` + `internal/remediation` service (state machine + projection, + no host contact). Backend-first, the same layering used for exceptions. +2. Free API endpoints (`request`/`approve`/`reject`/list/get) + audit wiring. +3. Frontend: request affordance on the Compliance tab + read surfaces on the + Remediation tab + license-upsell rendering of the act buttons. +4. Hand off the **act** verbs (`dry-run`/`execute`/`rollback`) to the companion + doc's Tier A, which reuses this schema and service. + +This is the GA **beta** remediation slice's free half. Execution is beta-in-GA +per `scan_remaining_work.md`; the free governance loop can ship first and stand +on its own. + +--- + +## 8. Open decisions (carried from the design discussion) + +- **D-1 (line placement).** Keep "any host mutation = paid" (current in-tree + encoding, recommended), or carve out free *manual single-host single-rule* + execution? Keeping it is cleaner and is what the registry already encodes; + the cost is a possible "approve, then paywall at execute" funnel feel, + mitigated by honest upsell copy. **Recommend: keep.** +- **D-2 / D-3** are about the paid tiers and the enforcement model — see the + companion doc. diff --git a/docs/engineering/remediation_licensed_plan.md b/docs/engineering/remediation_licensed_plan.md new file mode 100644 index 00000000..43e57c75 --- /dev/null +++ b/docs/engineering/remediation_licensed_plan.md @@ -0,0 +1,201 @@ +# Remediation — OpenWatch+ (Licensed) Plan + +> **Companion doc:** [`remediation_core_plan.md`](remediation_core_plan.md) +> covers the free AGPLv3 half (see-and-govern). This doc covers the **paid** +> capabilities: the act of mutating a host, and the fleet automation on top. +> +> **Status:** scoping / design. Builds on the same schema + `internal/remediation` +> service defined in the core doc; adds the license-gated act path and a +> second feature for the automation engine. +> +> **Ratified (2026-06-18):** **auto-remediation is an OpenWatch+ (licensed) +> feature.** Tier B below is paid; it is not part of the free AGPL core. The +> remaining open points are the *granularity* (own key vs. shared) and *SKU +> level* of that gate (D-2) and *where the code lives* (D-3). + +--- + +## 1. The two paid tiers + +The prototype shows two distinct paid surfaces with very different value and +risk. They should be **two feature keys**, not one (Decision D-2). + +| Tier | Feature key | Capability | Prototype surface | +|---|---|---|---| +| **A — Apply** | `remediation_execution` *(exists)* | Dry-run, execute, and rollback a fix on a **single host**, operator-driven, one rule (or one request) at a time. | Host Detail → Compliance "Remediate", Remediation tab per-txn **Rollback** | +| **B — Automate** | `remediation_auto` *(proposed, new)* | Fleet/bulk remediation, remediation **groups**, and the **auto-remediation policy engine**: per-severity auto-fix/approve/off, scope-by-group, canary-first, max-changes-per-run, circuit breaker, scheduled playbooks. | Scans → **Configuration** (auto-remediation), Host Detail "Remediate all · groups" | + +Tier A is "let me fix this one thing and prove it." Tier B is "keep the fleet +compliant without me clicking" — the most powerful and most dangerous surface, +and where the commercial value concentrates. + +--- + +## 2. Tier A — `remediation_execution` (the act of applying) + +### 2.1 What it is + +The three act verbs already gated in `auth/permissions.yaml`: +`remediation:execute` (dry-run + execute) and `remediation:rollback`, both +`license_gated: remediation_execution`, `dangerous: true`. The skeleton +`api/remediation.yaml` already marks `:dry-run`, `:execute`, `:rollback` as +requiring the feature. + +### 2.2 Where the code lives — Decision D-3 + +Tier A is built **in the core tree** (`internal/remediation`), gated at the +handler by `EnforceFeature(remediation_execution)`. This is an *honor-system + +friction* gate: under AGPLv3 the execute code is publishable source a user +could recompile without the check. **That is an accepted posture for Tier A** +because: + +- The manual single-host primitive (apply one Kensa transaction, capture + pre-state, rollback) is small and intrinsic to the remediation engine the + free governance loop already references. +- The license here is about legitimacy, support, and audit, not DRM. + +The robust open-core treatment is reserved for Tier B (§3.3), where it is worth +the architectural cost. + +### 2.3 Execution model (first slice) + +Per-rule, per-host, **approval-gated**, **snapshot + rollback** — exactly the +`scan_remaining_work.md` first slice. Flow: + +1. `:dry-run` — Kensa `Capture → Apply → Validate` with no `Commit`; returns the + would-be transaction + projected lift. Free users see the *plan* (read), paid + users can *run* the dry-run. +2. `:execute` — full `Capture → Apply → Validate → Commit`; writes the + `remediation_transactions` journal row with signed evidence; re-scan the + rule to confirm state flip; emit `remediation.executed`. +3. `:rollback` — restore from the captured pre-state; emit + `remediation.rolled_back`. + +### 2.4 Kensa work + +- Wire `kensa.Remediate()` (available in v0.5.0) through `internal/kensa`. +- Implement transport `Put`/`Get` **only if** a mechanism needs to push a helper + payload (`transport.go` currently returns `ErrTransportOpNotSupported`). The + scan path proves `Run` is enough for command-based checks; many handlers + (`config_set`, `service_enabled`, `sysctl_set`) are command-only. + +### 2.5 API + audit + +Promote the act endpoints in `api/remediation.yaml` to full fidelity; they +already carry `x-required-feature: remediation_execution` and 402 responses. +Audit codes `remediation.executed` (with `dry_run` flag, steps succeeded/failed) +and `remediation.rolled_back` already exist. + +--- + +## 3. Tier B — `remediation_auto` (the automation engine) + +### 3.1 What it is (the prototype's Scans → Configuration screen) + +- **Policy by severity** — High/Med/Low each: auto-fix · require-approval · off. +- **Scope & guardrails** — auto-remediate only in named groups + (e.g. "Development only"); **canary-first** (one host, validate, then the + rest); **max changes per run**; **circuit breaker** (pause all auto-remediation + if rollbacks exceed N). +- **Bulk / groups** — "Remediate all High & Med · N rules"; themed + **remediation groups** ("Harden SSH", "Enable firewall", "Install auditd") + each showing the multi-framework lift. +- **Scheduled / cadence playbooks** — auto-remediation on the adaptive schedule. + +### 3.2 Hard dependency — Kensa rule ordering (carries `scan_remaining_work.md` D-4) + +Bulk and grouped remediation need rule **ordering / grouping** metadata +(`depends_on` / `conflicts` / `supersedes`). Kensa's `LoadRules` deliberately +does **not** expose this today. Tier B's groups and "remediate all" are +**blocked on a Kensa-team ratification**, not an OpenWatch-only build. Per-rule +manual (Tier A) has no such dependency, which is one more reason Tier A ships +first. + +### 3.3 Where the code lives — Decision D-3 (the robust seam) + +Tier B is the right place to spend the open-core architecture cost. Recommended: +build the **auto-remediation policy engine as a separate licensed module** +loaded through the existing plugin interface (ORSA), **not** in the AGPL core. +Rationale: + +- It is the flagship paid capability and the most defensible to truly gate. +- It is the highest blast-radius surface (unattended fleet mutation); keeping it + behind a real boundary is also a safety win, not only a licensing one. +- A module that is physically absent without a license is an *enforceable* cap, + unlike the in-core honor-system gate acceptable for Tier A. + +The core exposes the Tier-A primitive (apply one rule, rollback) as the +interface; the Tier-B module orchestrates it (policy evaluation, fleet fan-out, +canary, circuit breaker, scheduling). + +### 3.4 Feature registry change + +Add to `licensing/features.yaml`: + +```yaml + - id: remediation_auto + tier: openwatch_plus # or `enterprise` if it should be a higher SKU — D-2 + description: Policy-driven and fleet/bulk auto-remediation (canary, circuit + breaker, scheduled playbooks, remediation groups) + introduced: "" +``` + +Then `go generate ./internal/license/...`, reference it from the auto-remediation +routes' `x-required-feature`, and (if a new perm is warranted) a +`remediation:auto` permission gated on it. CI (`scripts/validate-features.go`) +enforces that every gated reference resolves to a registered feature. + +### 3.5 New surfaces + +- **API:** an auto-remediation policy resource (`GET/PUT + /api/v1/remediation/policy`), bulk/group execute endpoints, all gated on + `remediation_auto`. +- **Frontend:** the Scans → Configuration auto-remediation panel and the Host + Detail "Remediate all / groups" cards, rendered as upsell when unlicensed. +- **Audit:** likely new codes for policy changes and auto-runs + (`remediation.policy.changed`, `remediation.auto.run`) — register before use. + +--- + +## 4. Specs to author (SDD) + +- `system-remediation-policy` — the policy data model, severity routing, + guardrails (canary, max-changes, circuit breaker), and the Tier-B module + boundary. +- `api-remediation` (extend) — the gated act endpoints (Tier A) and the policy / + bulk endpoints (Tier B), 402 contracts. +- `frontend-scan-remediation-config` — the Scans Configuration auto-remediation + surface + upsell rendering. + +--- + +## 5. Sequencing + +1. **Tier A first**, on the core schema/service: wire `kensa.Remediate`, + build `:dry-run`/`:execute`/`:rollback` gated by `remediation_execution`, + per-rule manual + approval + rollback. Ship as GA **beta**. +2. Prove Tier A on a real host (the test fleet) before any automation. +3. **Tier B second**, after (a) Kensa ratifies rule ordering (§3.2) and (b) the + `remediation_auto` feature + plugin module boundary are agreed. Build the + policy engine in the licensed module; start with bulk/manual groups, then + the auto/scheduled posture last (riskiest). + +--- + +## 6. Open decisions + +- **D-1 (line placement).** Recommended: keep "any host mutation = paid" + (Tier A gates all of dry-run/execute/rollback). See core doc §8. +- **D-2 (one tier or two).** *Auto-remediation is licensed — ratified + 2026-06-18.* Still to confirm: give it its **own key** `remediation_auto` + (recommended, so it can be priced/tiered independently of single-host apply) + vs. folding it under the existing `remediation_execution`; and whether that key + is `openwatch_plus` or a higher `enterprise` SKU. +- **D-3 (enforcement model / code location).** Recommended graduated answer: + Tier A **in-core, honor-system gate** (pragmatic, small primitive); Tier B as + a **separate licensed plugin module** (robustly enforceable, safety boundary). + This is the most consequential fork — it sets where the auto-remediation + engine gets built. +- **D-4 (Kensa ordering).** Bulk/grouped remediation requires a Kensa-team + ratification of rule ordering before it can be built. Tracks + `scan_remaining_work.md` decision #4. diff --git a/frontend/src/api/auth-bootstrap.ts b/frontend/src/api/auth-bootstrap.ts index 64ef2684..d8dc15ea 100644 --- a/frontend/src/api/auth-bootstrap.ts +++ b/frontend/src/api/auth-bootstrap.ts @@ -27,6 +27,17 @@ function permissionsForRole(role: string): string[] { 'credential:delete', 'scan:read', 'audit:read', + 'compliance:read', + 'exception:read', + 'exception:request', + 'exception:approve', + 'exception:revoke', + 'exception:comment', + 'remediation:read', + 'remediation:request', + 'remediation:approve', + 'remediation:execute', + 'remediation:rollback', 'notification:read', 'notification:write', 'notification:delete', diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 889471ed..7554df09 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1118,6 +1118,191 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/remediation/requests": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List remediation requests (fleet or filtered) + * @description Remediation request queue, newest first, optionally filtered by + * status, host_id, or rule_id; capped at limit (default 200). RBAC: + * remediation:read. Spec api-remediation. + */ + get: operations["listRemediationRequests"]; + put?: never; + /** + * Request a remediation (fix) for a failing rule on a host + * @description Submits a pending_approval remediation request for a host+rule and + * records the projected per-framework compliance lift. One open request + * is allowed per host+rule; a duplicate returns 409. The requester is + * the authenticated user. This NEVER contacts the host. RBAC: + * remediation:request. Spec api-remediation. + */ + post: operations["requestRemediation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/remediation/requests/{rid}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a remediation request + * @description RBAC: remediation:read. Spec api-remediation. + */ + get: operations["getRemediationRequest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/remediation/requests/{rid}/steps": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a remediation request's transaction journal (steps) + * @description Per-step Kensa transaction journal (Capture/Apply/Validate/Commit + * outcomes). Empty until the request is executed (executing is the + * OpenWatch+ licensed track). RBAC: remediation:read. Spec + * api-remediation. + */ + get: operations["listRemediationSteps"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/remediation/requests/{rid}:approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Approve a pending remediation request + * @description pending_approval -> approved. The reviewer must differ from the + * requester (separation of duties, 409). RBAC: remediation:approve. + * Spec api-remediation. + */ + post: operations["approveRemediation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/remediation/requests/{rid}:reject": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reject a pending remediation request + * @description pending_approval -> rejected. The reviewer must differ from the + * requester (409). RBAC: remediation:approve. Spec api-remediation. + */ + post: operations["rejectRemediation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/remediation/requests/{rid}:dry-run": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Dry-run a remediation (OpenWatch+) + * @description Connects to the host and reports what would change without applying. + * License-gated: requires remediation:execute AND the + * remediation_execution feature; returns 402 on the free tier. The + * execution body is the OpenWatch+ licensed track. Spec api-remediation. + */ + post: operations["dryRunRemediation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/remediation/requests/{rid}:execute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Execute a remediation against the host (OpenWatch+) + * @description Applies the fix (Capture/Apply/Validate/Commit). License-gated: + * requires remediation:execute AND remediation_execution; returns 402 on + * the free tier. The execution body is the OpenWatch+ licensed track. + * Spec api-remediation. + */ + post: operations["executeRemediation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/remediation/requests/{rid}:rollback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Roll back an executed remediation (OpenWatch+) + * @description Restores the captured pre-state. License-gated: requires + * remediation:rollback AND remediation_execution; returns 402 on the + * free tier. The execution body is the OpenWatch+ licensed track. Spec + * api-remediation. + */ + post: operations["rollbackRemediation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/groups": { parameters: { query?: never; @@ -3030,6 +3215,73 @@ export interface components { /** @description Optional reviewer note */ note?: string; }; + /** @description Estimated per-framework compliance-score delta (percentage points) if the rule flips to pass. Best-effort; a field is null when that framework's data is unavailable for the host. */ + ProjectedLift: { + /** Format: double */ + cis?: number | null; + /** Format: double */ + stig?: number | null; + /** Format: double */ + nist?: number | null; + }; + RemediationRequest: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + host_id: string; + /** @description Hostname, populated on list responses (empty on single-row lifecycle results) */ + host_name?: string; + rule_id: string; + /** @enum {string} */ + status: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed"; + /** Format: uuid */ + requested_by: string; + /** Format: uuid */ + reviewed_by?: string | null; + review_note?: string; + /** Format: uuid */ + scan_run_id?: string | null; + /** @description Kensa remediation handler id (empty when unknown at request time) */ + mechanism?: string; + reboot_required?: boolean; + transactional?: boolean; + projected_lift?: components["schemas"]["ProjectedLift"]; + /** Format: date-time */ + requested_at: string; + /** Format: date-time */ + reviewed_at?: string | null; + }; + RemediationRequestList: { + requests: components["schemas"]["RemediationRequest"][]; + }; + RemediationRequestCreate: { + /** Format: uuid */ + host_id: string; + rule_id: string; + /** + * Format: uuid + * @description Optional provenance - the scan run whose finding this remediates + */ + scan_run_id?: string | null; + }; + RemediationReview: { + /** @description Optional reviewer note */ + note?: string; + }; + RemediationStep: { + /** Format: uuid */ + id: string; + rule_id: string; + mechanism?: string; + /** @enum {string|null} */ + phase_result?: "committed" | "rolled_back" | "skipped" | null; + dry_run: boolean; + /** Format: date-time */ + applied_at?: string | null; + }; + RemediationStepList: { + steps: components["schemas"]["RemediationStep"][]; + }; Group: { /** Format: uuid */ id: string; @@ -5993,6 +6245,398 @@ export interface operations { }; }; }; + listRemediationRequests: { + parameters: { + query?: { + status?: "pending_approval" | "approved" | "rejected" | "dry_run_complete" | "executing" | "executed" | "rolled_back" | "failed"; + host_id?: string; + rule_id?: string; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Remediation requests, newest first */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RemediationRequestList"]; + }; + }; + /** @description Caller lacks remediation:read permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + requestRemediation: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RemediationRequestCreate"]; + }; + }; + responses: { + /** @description The created remediation request (pending approval) */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RemediationRequest"]; + }; + }; + 400: components["responses"]["BadRequest"]; + /** @description Caller lacks remediation:request permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Unknown or deleted host */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description An open remediation request already exists for this host and rule */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getRemediationRequest: { + parameters: { + query?: never; + header?: never; + path: { + rid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The remediation request */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RemediationRequest"]; + }; + }; + /** @description Caller lacks remediation:read permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Remediation request not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + listRemediationSteps: { + parameters: { + query?: never; + header?: never; + path: { + rid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Steps, in apply order */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RemediationStepList"]; + }; + }; + /** @description Caller lacks remediation:read permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Remediation request not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + approveRemediation: { + parameters: { + query?: never; + header?: never; + path: { + rid: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RemediationReview"]; + }; + }; + responses: { + /** @description The approved remediation request */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RemediationRequest"]; + }; + }; + /** @description Caller lacks remediation:approve permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Remediation request not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Not in the pending_approval state, or reviewer is the requester */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + rejectRemediation: { + parameters: { + query?: never; + header?: never; + path: { + rid: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["RemediationReview"]; + }; + }; + responses: { + /** @description The rejected remediation request */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RemediationRequest"]; + }; + }; + /** @description Caller lacks remediation:approve permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Remediation request not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Not in the pending_approval state, or reviewer is the requester */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + dryRunRemediation: { + parameters: { + query?: never; + header?: never; + path: { + rid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description License does not include remediation_execution */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Caller lacks remediation:execute permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Licensed execution body not yet implemented (OpenWatch+ track) */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + executeRemediation: { + parameters: { + query?: never; + header?: never; + path: { + rid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description License does not include remediation_execution */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Caller lacks remediation:execute permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Licensed execution body not yet implemented (OpenWatch+ track) */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + rollbackRemediation: { + parameters: { + query?: never; + header?: never; + path: { + rid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description License does not include remediation_execution */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Caller lacks remediation:rollback permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Licensed execution body not yet implemented (OpenWatch+ track) */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; getGroups: { parameters: { query?: never; diff --git a/frontend/src/components/hosts/RequestRemediationModal.tsx b/frontend/src/components/hosts/RequestRemediationModal.tsx new file mode 100644 index 00000000..2cd03720 --- /dev/null +++ b/frontend/src/components/hosts/RequestRemediationModal.tsx @@ -0,0 +1,164 @@ +// RequestRemediationModal confirms a remediation request for a single +// failing rule, then POSTs /api/v1/remediation/requests {host_id, +// rule_id}. The created row carries the projected compliance lift, so +// the modal previews the lift the host's compliance score should gain +// once the fix is approved and applied (the apply step itself is the +// OpenWatch+ track and is not driven from here). +// +// The host-detail remediations query is refreshed by the parent on +// success. A 409 (an open remediation already exists for this rule) +// surfaces inline. UI copy carries no em-dashes (project rule). +// +// Spec: frontend-remediation-tab AC-02. + +import type { CSSProperties } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@/api/client'; +import { apiErrorMessage } from '@/api/errors'; +import type { components } from '@/api/schema'; + +type ProjectedLift = components['schemas']['ProjectedLift']; + +// formatLift renders the projected per-framework lift as a compact +// readout, e.g. "CIS +33.33 / STIG +50". Returns null when the API +// supplied no lift figures (the create response may omit it). +export function formatLift(lift?: ProjectedLift | null): string | null { + if (!lift) return null; + const parts: string[] = []; + const push = (label: string, v?: number | null) => { + if (v != null && v !== 0) parts.push(`${label} +${Number(v.toFixed(2))}`); + }; + push('CIS', lift.cis); + push('STIG', lift.stig); + push('NIST', lift.nist); + return parts.length > 0 ? parts.join(' / ') : null; +} + +export function RequestRemediationModal({ + hostId, + ruleId, + ruleTitle, + onClose, + onSuccess, +}: { + hostId: string; + ruleId: string; + ruleTitle: string; + onClose: () => void; + onSuccess: () => void; +}) { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async () => { + const { error, response } = await api.POST('/api/v1/remediation/requests', { + body: { host_id: hostId, rule_id: ruleId }, + }); + if (error || !response.ok) { + if (response.status === 409) { + throw new Error('A remediation has already been requested for this rule.'); + } + throw new Error(apiErrorMessage(error, `Request failed (${response.status})`)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['host', hostId, 'remediations'] }); + onSuccess(); + }, + }); + + return ( +
+
e.stopPropagation()} + style={{ + width: 460, + maxWidth: '90vw', + background: 'var(--ow-bg-1)', + border: '1px solid var(--ow-line)', + borderRadius: 'var(--ow-radius)', + padding: 20, + }} + > +

Request remediation

+
{ruleTitle}
+
+ {ruleId} +
+ +

+ This files a remediation request for review. An approver decides whether the fix is + applied. Applying the fix on the host is an OpenWatch+ feature: the free tier governs the + request and approval only. +

+ + {mutation.error && ( +
+ {(mutation.error as Error).message} +
+ )} + +
+ + +
+
+
+ ); +} + +const cancelBtn: CSSProperties = { + height: 32, + padding: '0 14px', + background: 'var(--ow-bg-2)', + color: 'var(--ow-fg-1)', + border: '1px solid var(--ow-line)', + borderRadius: 7, + fontSize: 12, + cursor: 'pointer', +}; + +const submitBtn: CSSProperties = { + height: 32, + padding: '0 14px', + background: 'var(--ow-info)', + color: 'var(--ow-info-on)', + border: 0, + borderRadius: 7, + fontSize: 12, + fontWeight: 600, +}; diff --git a/frontend/src/hooks/useHostRemediations.ts b/frontend/src/hooks/useHostRemediations.ts new file mode 100644 index 00000000..d7a457af --- /dev/null +++ b/frontend/src/hooks/useHostRemediations.ts @@ -0,0 +1,74 @@ +import { useQuery } from '@tanstack/react-query'; +import api from '@/api/client'; +import { apiErrorMessage } from '@/api/errors'; +import type { components } from '@/api/schema'; + +type RemediationRequest = components['schemas']['RemediationRequest']; + +// useHostRemediations fetches a host's remediation requests once and +// derives the views the host-detail surfaces need. The query key +// carries the ['host', hostId] prefix so the scan.completed SSE +// invalidation and remediation mutations both refresh it, and so the +// Compliance tab affordance and the Remediation tab share a single +// round-trip. +// +// Free-tier lifecycle: pending_approval -> approved | rejected. The +// act verbs (dry_run/execute/rollback) are OpenWatch+ only and never +// called from the free build; the matching statuses +// (dry_run_complete/executing/executed/rolled_back) can still arrive +// from a licensed deployment, so the open set accounts for them. +// +// openRuleIds annotates which rules already carry an in-flight +// remediation (so the per-rule affordance suppresses a duplicate +// request). It never mutates the lens verdict. +const OPEN_STATUSES: ReadonlySet = new Set([ + 'pending_approval', + 'approved', + 'dry_run_complete', + 'executing', +]); + +export interface HostRemediations { + items: RemediationRequest[]; + openRuleIds: Set; + pendingRuleIds: Set; + pendingCount: number; + isPending: boolean; + isError: boolean; + refetch: () => void; +} + +export function useHostRemediations(hostId: string): HostRemediations { + const query = useQuery({ + queryKey: ['host', hostId, 'remediations'], + queryFn: async (): Promise => { + const { data, error, response } = await api.GET('/api/v1/remediation/requests', { + params: { query: { host_id: hostId } }, + }); + if (error || !response.ok) { + throw new Error(apiErrorMessage(error, `Failed to load (${response.status})`)); + } + return data!.requests; + }, + enabled: !!hostId, + }); + + const items = query.data ?? []; + // Newest first: the list panel and any "already requested" lookup + // both want the most recent request for a rule to win. + const sorted = [...items].sort( + (a, b) => new Date(b.requested_at).getTime() - new Date(a.requested_at).getTime(), + ); + const open = sorted.filter((r) => OPEN_STATUSES.has(r.status)); + const pending = sorted.filter((r) => r.status === 'pending_approval'); + + return { + items: sorted, + openRuleIds: new Set(open.map((r) => r.rule_id)), + pendingRuleIds: new Set(pending.map((r) => r.rule_id)), + pendingCount: pending.length, + isPending: query.isPending, + isError: query.isError, + refetch: () => void query.refetch(), + }; +} diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 3631a6d9..ddcb7a7a 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useParams, useSearch, useNavigate, Link } from '@tanstack/react-router'; -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; import { Activity as ActivityIcon, AlertTriangle, @@ -27,6 +27,8 @@ import { import type { LucideIcon } from 'lucide-react'; import api from '@/api/client'; import { useHostExceptions } from '@/hooks/useHostExceptions'; +import { useHostRemediations } from '@/hooks/useHostRemediations'; +import { formatLift } from '@/components/hosts/RequestRemediationModal'; import { apiErrorCode, apiErrorMessage } from '@/api/errors'; import { EditHostModal } from '@/components/hosts/EditHostModal'; import { HostCredentialModal } from '@/components/hosts/HostCredentialModal'; @@ -178,7 +180,10 @@ const TAB_ORDER: { id: TabId; label: string; icon: LucideIcon }[] = [ // Backend subsystem that populates each tab when it lands. Surfaces // inside the per-tab empty state so operators know what's deferred. -const TAB_BACKEND_SUBSYSTEM: Record, string> = { +const TAB_BACKEND_SUBSYSTEM: Record< + Exclude, + string +> = { packages: 'Server Intelligence collection — installed-package inventory deferred (BACKLOG).', services: 'Server Intelligence collection — running services inventory deferred (BACKLOG).', users: 'Server Intelligence collection — user accounts inventory deferred (BACKLOG).', @@ -186,7 +191,6 @@ const TAB_BACKEND_SUBSYSTEM: Record, s audit_log: 'Audit query API — host-scoped audit feed deferred to the unified /activity page (BACKLOG).', activity: 'Unified Activity feed — combined transactions + audits + alerts deferred (BACKLOG).', - remediation: 'Remediation engine — Kensa-side remediation pipeline deferred (BACKLOG).', terminal: 'Web terminal — SSH-in-browser deferred; use a host-side SSH client in the meantime.', }; @@ -480,6 +484,8 @@ export function HostDetailPage() { : null } /> + ) : activeTab === 'remediation' ? ( + ) : ( )} @@ -1110,6 +1116,400 @@ function TabStub({ tab, subsystem }: { tab: TabId; subsystem: string }) { ); } +// ───────────────────────────────────────────────────────────────────────── +// Remediation tab — free-tier governance surface. +// +// Read-only list of this host's remediation requests (useHostRemediations, +// newest first). The free tier drives only the request -> approve | reject +// lifecycle: an approver with remediation:approve sees Approve / Reject on +// pending rows; everyone else sees "Awaiting approval". The host-mutating +// apply step (dry-run / execute / rollback) is an OpenWatch+ feature, +// rendered as a DISABLED upsell control that is never wired to the act +// endpoints (they return 402 on the free tier). +// +// Spec: frontend-remediation-tab AC-03. +// ───────────────────────────────────────────────────────────────────────── + +const REM_STATUS_STYLE: Record = { + pending_approval: { fg: 'var(--ow-warn)', bg: 'var(--ow-warn-bg)', label: 'Pending approval' }, + approved: { fg: 'var(--ow-info)', bg: 'var(--ow-bg-2)', label: 'Approved' }, + rejected: { fg: 'var(--ow-fg-3)', bg: 'var(--ow-bg-2)', label: 'Rejected' }, + dry_run_complete: { fg: 'var(--ow-info)', bg: 'var(--ow-bg-2)', label: 'Dry-run complete' }, + executing: { fg: 'var(--ow-warn)', bg: 'var(--ow-warn-bg)', label: 'Executing' }, + executed: { fg: 'var(--ow-ok)', bg: 'var(--ow-ok-bg)', label: 'Executed' }, + rolled_back: { fg: 'var(--ow-fg-2)', bg: 'var(--ow-bg-2)', label: 'Rolled back' }, + failed: { fg: 'var(--ow-crit)', bg: 'var(--ow-crit-bg)', label: 'Failed' }, +}; + +function RemStatusChip({ status }: { status: string }) { + const s = REM_STATUS_STYLE[status] ?? { + fg: 'var(--ow-fg-2)', + bg: 'var(--ow-bg-2)', + label: status, + }; + return ( + + + {s.label} + + ); +} + +function RemediationTab({ hostId }: { hostId: string }) { + const rem = useHostRemediations(hostId); + const canApprove = useAuthStore((s) => s.hasPermission('remediation:approve')); + + let body: ReactNode; + if (rem.isPending) { + body = ( +
+ Loading remediation requests +
+ ); + } else if (rem.isError) { + body = ( +
+ Failed to load remediation requests.{' '} + +
+ ); + } else if (rem.items.length === 0) { + body = ( +
+ No remediation requests for this host yet. Request a fix from a failing rule on the + Compliance tab. +
+ ); + } else { + body = ( + + + + Status + Rule + Projected lift + Action + + + + {rem.items.map((r) => { + const lift = formatLift(r.projected_lift); + return ( + + + + + + + ); + })} + +
+ + + + {r.rule_id} + + + {lift ?? } + + +
+ ); + } + + return ( +
+ +
+

Remediation requests

+ {body} +
+ +
+ ); +} + +// RemediationExplainer states the atomic transaction model as static +// copy (the model the OpenWatch+ apply step follows): Capture, Apply, +// Validate, Commit, with a rollback to the captured state on failure. +function RemediationExplainer() { + const phases = ['Capture', 'Apply', 'Validate', 'Commit']; + return ( +
+
+ Atomic remediation model +
+
+ {phases.map((p, i) => ( + + + {p} + + {i < phases.length - 1 ? ( + + ) : null} + + ))} +
+
+ Each approved fix captures the current host state, applies the change, validates the result, + then commits. A failed validation rolls back to the captured state, so a host is never left + half-fixed. The free tier governs the request and approval. Applying the fix on the host is + an OpenWatch+ feature. +
+
+ ); +} + +// RemediationRowAction renders the per-row free-tier action. On a +// pending_approval row, a caller with remediation:approve gets Approve +// and Reject (POST :approve / :reject, invalidate on success, 409 +// inline); without the permission, "Awaiting approval". Non-pending +// rows are terminal or in the licensed track and render a dash. +function RemediationRowAction({ + request, + hostId, + canApprove, +}: { + request: { id: string; status: string }; + hostId: string; + canApprove: boolean; +}) { + const queryClient = useQueryClient(); + const [note, setNote] = useState(null); + + const review = useMutation({ + mutationFn: async (action: 'approve' | 'reject') => { + const path = `/api/v1/remediation/requests/{rid}:${action}` as + | '/api/v1/remediation/requests/{rid}:approve' + | '/api/v1/remediation/requests/{rid}:reject'; + const { error, response } = await api.POST(path, { + params: { path: { rid: request.id } }, + body: {}, + }); + if (error || !response.ok) { + if (response.status === 409) { + // The backend distinguishes the two 409 reasons by code: a + // separation-of-duties block (you requested it) versus the row + // having already been actioned by someone else. Surface the real + // one rather than a single blanket message. + const code = (error as { error?: { code?: string } } | undefined)?.error?.code; + if (code === 'remediation.self_review') { + throw new Error( + 'You cannot approve or reject your own request. A different reviewer must action it.', + ); + } + throw new Error(apiErrorMessage(error, 'This request already changed state.')); + } + throw new Error(apiErrorMessage(error, `Review failed (${response.status})`)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['host', hostId, 'remediations'] }); + }, + onError: (e: Error) => { + setNote(e.message); + window.setTimeout(() => setNote(null), 5000); + }, + }); + + if (request.status !== 'pending_approval') { + return ; + } + if (!canApprove) { + return Awaiting approval; + } + return ( + + + + {note && ( + + {note} + + )} + + ); +} + +// RemediationUpsell renders the host-mutating apply affordance as a +// DISABLED OpenWatch+ control. It is intentionally NOT wired to the +// host-mutating act endpoints (dry-run, execute, rollback), which +// return 402 on the free tier. There is no frontend entitlement hook +// yet, so the upsell always shows. +// TODO: when a frontend license/entitlement hook lands, hide this when +// the remediation_execution feature is licensed and surface the live +// Execute / Rollback controls instead. +function RemediationUpsell() { + return ( +
+
+
+ Apply fixes automatically (OpenWatch+) +
+
+ Approving a request records the decision. Applying the fix on the host (dry-run, execute, + and rollback) is an OpenWatch+ feature. The free tier stops at governance. +
+
+ +
+ ); +} + +function RemTh({ children, width }: { children: ReactNode; width?: number }) { + return ( + + {children} + + ); +} + +const remTd: CSSProperties = { + padding: '9px 10px 9px 0', + verticalAlign: 'top', +}; + // ───────────────────────────────────────────────────────────────────────── // Hero stat strip — band 5 // ───────────────────────────────────────────────────────────────────────── diff --git a/frontend/src/pages/host-detail/ComplianceTab.tsx b/frontend/src/pages/host-detail/ComplianceTab.tsx index 5090aa8f..e7ce67b5 100644 --- a/frontend/src/pages/host-detail/ComplianceTab.tsx +++ b/frontend/src/pages/host-detail/ComplianceTab.tsx @@ -49,6 +49,8 @@ import type { components } from '@/api/schema'; import { useAuthStore } from '@/store/useAuthStore'; import { RuleDetailPanel } from '@/pages/scans/RuleDetailPanel'; import { useHostExceptions } from '@/hooks/useHostExceptions'; +import { useHostRemediations } from '@/hooks/useHostRemediations'; +import { RequestRemediationModal } from '@/components/hosts/RequestRemediationModal'; import { SeverityPill } from '@/pages/host-detail/SeverityPill'; type LensResponse = components['schemas']['HostComplianceLensResponse']; @@ -121,11 +123,18 @@ export function ComplianceTab({ // a pending request. Never mutates the lens data (overlay model). const exc = useHostExceptions(hostId); const canRequest = useAuthStore((st) => st.hasPermission)('exception:request'); + // Remediation overlay: which failing rules already carry an in-flight + // remediation request (open set), to suppress a duplicate per-rule + // action. Parallel to the exception overlay; never mutates the lens. + const rem = useHostRemediations(hostId); + const canRequestRemediation = useAuthStore((st) => st.hasPermission)('remediation:request'); // Evidence drill-down is gated scan:read (it reaches the scan:read-only // /scans evidence endpoints): the host lens itself stays evidence-free. const canViewEvidence = useAuthStore((st) => st.hasPermission)('scan:read'); // The rule a Request-exception modal is open for (null = closed). const [requestRule, setRequestRule] = useState(null); + // The rule a Request-remediation modal is open for (null = closed). + const [remediateRule, setRemediateRule] = useState(null); let body: ReactNode; // isPending (not isLoading): isLoading goes false between retry @@ -209,6 +218,9 @@ export function ComplianceTab({ pendingRuleIds={exc.pendingRuleIds} canRequest={canRequest} onRequest={setRequestRule} + remediationOpenRuleIds={rem.openRuleIds} + canRequestRemediation={canRequestRemediation} + onRequestRemediation={setRemediateRule} scanId={lens.scan_context.scan_id ?? null} canViewEvidence={canViewEvidence} /> @@ -245,6 +257,18 @@ export function ComplianceTab({ }} /> )} + {remediateRule && ( + setRemediateRule(null)} + onSuccess={() => { + setRemediateRule(null); + rem.refetch(); + }} + /> + )} ); } @@ -925,6 +949,9 @@ function RulesTable({ pendingRuleIds, canRequest, onRequest, + remediationOpenRuleIds, + canRequestRemediation, + onRequestRemediation, scanId, canViewEvidence, }: { @@ -938,6 +965,9 @@ function RulesTable({ pendingRuleIds: Set; canRequest: boolean; onRequest: (rule: LensRule) => void; + remediationOpenRuleIds: Set; + canRequestRemediation: boolean; + onRequestRemediation: (rule: LensRule) => void; // scanId is the host's latest completed scan (scan_context.scan_id); the // per-rule evidence drill-down reaches /scans/{scanId}/rules/{ruleId}. // null when never scanned. canViewEvidence gates it on scan:read. @@ -1068,6 +1098,7 @@ function RulesTable({ Category Last checked Exception + Remediation @@ -1137,10 +1168,18 @@ function RulesTable({ onRequest={onRequest} /> + + + {canDrill && open && scanId ? ( - + @@ -1287,6 +1326,55 @@ const excPill: CSSProperties = { whiteSpace: 'nowrap', }; +// RemediationCell renders, for a rule's row, the remediation governance +// state: a "Remediation requested" pill when an open request already +// exists, or - for an unwaived FAILING rule and a caller with +// remediation:request - a Request button that opens the confirm modal. +// Non-failing rules with no open request render nothing. This is the +// request/approval half of the workflow only; applying the fix on the +// host is the OpenWatch+ track surfaced on the Remediation tab. +function RemediationCell({ + rule, + requested, + canRequest, + onRequest, +}: { + rule: LensRule; + requested: boolean; + canRequest: boolean; + onRequest: (rule: LensRule) => void; +}) { + if (requested) { + return ( + + Remediation requested + + ); + } + if (rule.status === 'fail' && canRequest) { + return ( + + ); + } + return ; +} + // RequestExceptionModal collects the reason (required) and an optional // expiry, then POSTs /hosts/{id}/exceptions. The host-detail // exceptions query is refreshed by the parent on success. A 409 (an diff --git a/frontend/tests/pages/host-detail-compliance-tab.test.tsx b/frontend/tests/pages/host-detail-compliance-tab.test.tsx index fd285b7f..adb38d85 100644 --- a/frontend/tests/pages/host-detail-compliance-tab.test.tsx +++ b/frontend/tests/pages/host-detail-compliance-tab.test.tsx @@ -156,7 +156,8 @@ describe('frontend-host-compliance-tab — structural', () => { expect(PAGE_SRC).toContain(', string>"); + // remediation joined overview + compliance as a live (non-stub) tab. + expect(PAGE_SRC).toContain("Exclude"); }); // @ac AC-02 diff --git a/frontend/tests/pages/remediation-tab.test.ts b/frontend/tests/pages/remediation-tab.test.ts new file mode 100644 index 00000000..3b0dcd39 --- /dev/null +++ b/frontend/tests/pages/remediation-tab.test.ts @@ -0,0 +1,77 @@ +// @spec frontend-remediation-tab +// +// AC traceability (source inspection over the hook + ComplianceTab + +// RequestRemediationModal + HostDetailPage): +// AC-01 useHostRemediations: query key, endpoint, derived sets +// AC-02 Compliance tab per-rule affordance: gating, open-state +// suppression, POST body, 409 inline, no em-dashes +// AC-03 Remediation tab: approve/reject gating + invalidation, +// atomic-model explainer, OpenWatch+ upsell, act endpoints +// never referenced + +import { describe, expect, test } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (p: string) => readFileSync(resolve(process.cwd(), p), 'utf8'); + +const HOOK = read('src/hooks/useHostRemediations.ts'); +const COMPLIANCE = read('src/pages/host-detail/ComplianceTab.tsx'); +const MODAL = read('src/components/hosts/RequestRemediationModal.tsx'); +const PAGE = read('src/pages/HostDetailPage.tsx'); + +describe('frontend-remediation-tab — source inspection', () => { + // @ac AC-01 + test('frontend-remediation-tab/AC-01 — hook query key, endpoint, derived sets', () => { + expect(HOOK).toContain("queryKey: ['host', hostId, 'remediations']"); + expect(HOOK).toContain("api.GET('/api/v1/remediation/requests'"); + expect(HOOK).toContain('query: { host_id: hostId }'); + // openRuleIds covers the in-flight statuses. + expect(HOOK).toContain("'pending_approval'"); + expect(HOOK).toContain("'approved'"); + expect(HOOK).toContain("'dry_run_complete'"); + expect(HOOK).toContain("'executing'"); + // pendingRuleIds derives from pending_approval. + expect(HOOK).toContain("r.status === 'pending_approval'"); + expect(HOOK).toContain('openRuleIds'); + expect(HOOK).toContain('pendingRuleIds'); + }); + + // @ac AC-02 + test('frontend-remediation-tab/AC-02 — per-rule affordance gating, suppression, POST, 409', () => { + // Gated on remediation:request. + expect(COMPLIANCE).toContain("hasPermission)('remediation:request')"); + // Open-state suppression renders the "requested" pill instead of the action. + expect(COMPLIANCE).toContain('remediationOpenRuleIds'); + expect(COMPLIANCE).toContain('Remediation requested'); + expect(COMPLIANCE).toContain('Request remediation'); + // Modal POSTs the create endpoint with {host_id, rule_id}. + expect(MODAL).toContain("api.POST('/api/v1/remediation/requests'"); + expect(MODAL).toContain('host_id: hostId, rule_id: ruleId'); + // 409 maps to an inline message. + expect(MODAL).toMatch(/response\.status === 409/); + expect(MODAL).toContain('already been requested'); + // No em-dashes in the user-facing copy (project hard rule). + expect(MODAL).not.toContain('—'); + }); + + // @ac AC-03 + test('frontend-remediation-tab/AC-03 — tab gating, explainer, upsell, no act endpoints', () => { + // Approve/Reject gated on remediation:approve. + expect(PAGE).toContain("hasPermission('remediation:approve')"); + expect(PAGE).toContain('Awaiting approval'); + // Review POSTs the approve/reject endpoints and invalidates the host key. + expect(PAGE).toContain('/api/v1/remediation/requests/{rid}:approve'); + expect(PAGE).toContain('/api/v1/remediation/requests/{rid}:reject'); + expect(PAGE).toContain("queryKey: ['host', hostId, 'remediations']"); + expect(PAGE).toMatch(/response\.status === 409/); + // Atomic transaction model explainer (Capture -> Apply -> Validate -> Commit). + expect(PAGE).toContain("['Capture', 'Apply', 'Validate', 'Commit']"); + // OpenWatch+ upsell, disabled, never wired to the act endpoints. + expect(PAGE).toContain('Execute on host (OpenWatch+)'); + expect(PAGE).toContain('RemediationUpsell'); + expect(PAGE).not.toContain(':execute'); + expect(PAGE).not.toContain(':rollback'); + expect(PAGE).not.toContain(':dry-run'); + }); +}); diff --git a/internal/db/migrations/0037_remediation.sql b/internal/db/migrations/0037_remediation.sql new file mode 100644 index 00000000..c85f5c10 --- /dev/null +++ b/internal/db/migrations/0037_remediation.sql @@ -0,0 +1,86 @@ +-- 0037_remediation.sql +-- +-- Remediation governance (scan plan Phase 7, remediation half). The free +-- (AGPLv3 core) see-and-govern loop: an operator's intent to fix a failing +-- rule on a host, with a request -> approve | reject lifecycle, mirroring the +-- exception governance overlay (0026_compliance_exceptions). +-- +-- FREE-PATH INVARIANT: the free service (Request/Approve/Reject + the +-- read-only ProjectLift) NEVER contacts a host and NEVER writes host_rule_state +-- or transactions. remediation_transactions (the per-step Kensa journal) is +-- written only by the OpenWatch+ licensed execute path; in the free build the +-- table exists but stays empty. +-- +-- Lifecycle: pending_approval -> approved | rejected. The approved -> executed +-- -> rolled_back states are driven by the licensed execution track +-- (remediation_execution). Separation of duties (auth/permissions.yaml): +-- remediation:request is distinct from remediation:approve; remediation:execute +-- and remediation:rollback are dangerous and license-gated to +-- remediation_execution. +-- +-- Spec: api-remediation v1.0.0. Plan: docs/engineering/remediation_core_plan.md. + +-- +goose Up +CREATE TABLE remediation_requests ( + id UUID PRIMARY KEY, + host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + rule_id TEXT NOT NULL, + scan_run_id UUID REFERENCES scan_runs(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'pending_approval' + CHECK (status IN ('pending_approval','approved','rejected', + 'dry_run_complete','executing','executed', + 'rolled_back','failed')), + requested_by UUID NOT NULL REFERENCES users(id), + reviewed_by UUID REFERENCES users(id), + review_note TEXT, + -- Remediation shape, captured at request time from the rule's Kensa + -- metadata (best-effort; empty/false when unknown in the free path). + mechanism TEXT, + reboot_required BOOLEAN NOT NULL DEFAULT false, + transactional BOOLEAN NOT NULL DEFAULT true, + -- Projected per-framework score lift (percentage points) if the rule + -- flips to pass; a NULL means that framework's data was unavailable. + projected_cis DOUBLE PRECISION, + projected_stig DOUBLE PRECISION, + projected_nist DOUBLE PRECISION, + requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- At most ONE open remediation request per host+rule, so a duplicate request +-- cannot stack while one is in flight. Terminal rows (rejected / executed / +-- rolled_back / failed) are historical and do not block a fresh request. +CREATE UNIQUE INDEX remediation_requests_one_open + ON remediation_requests (host_id, rule_id) + WHERE status IN ('pending_approval','approved','dry_run_complete','executing'); + +-- Per-host listing (Compliance tab annotation) and fleet queue scans by state. +CREATE INDEX remediation_requests_host ON remediation_requests (host_id); +CREATE INDEX remediation_requests_status ON remediation_requests (status); + +-- Per-step Kensa transaction journal (Capture/Apply/Validate/Commit). Written +-- only by the OpenWatch+ licensed execute path; the durable rollback point and +-- signed-evidence record. Empty in the free build. +CREATE TABLE remediation_transactions ( + id UUID PRIMARY KEY, + request_id UUID NOT NULL REFERENCES remediation_requests(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL DEFAULT 0, + rule_id TEXT NOT NULL, + kensa_txn_id TEXT, + mechanism TEXT, + phase_result TEXT CHECK (phase_result IS NULL OR + phase_result IN ('committed','rolled_back','skipped')), + pre_state JSONB NOT NULL DEFAULT '{}'::jsonb, + evidence JSONB NOT NULL DEFAULT '{}'::jsonb, + dry_run BOOLEAN NOT NULL DEFAULT false, + applied_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX remediation_transactions_request ON remediation_transactions (request_id, ordinal); + +-- +goose Down +DROP TABLE IF EXISTS remediation_transactions; +DROP TABLE IF EXISTS remediation_requests; diff --git a/internal/remediation/service.go b/internal/remediation/service.go new file mode 100644 index 00000000..d69da8bc --- /dev/null +++ b/internal/remediation/service.go @@ -0,0 +1,367 @@ +package remediation + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Hanalyx/openwatch/internal/audit" +) + +// EmitFunc is the audit-emission shape (matches audit.Emit). Tests pass a fake. +type EmitFunc func(ctx context.Context, code audit.Code, ev audit.Event) + +// Service is the free remediation governance service. It never contacts a host. +type Service struct { + pool *pgxpool.Pool + emit EmitFunc +} + +// NewService wires the service. emit is audit.Emit in production. +func NewService(pool *pgxpool.Pool, emit EmitFunc) *Service { + return &Service{pool: pool, emit: emit} +} + +const selectCols = `id, host_id, rule_id, status, requested_by, reviewed_by, + COALESCE(review_note, ''), scan_run_id, COALESCE(mechanism, ''), + reboot_required, transactional, projected_cis, projected_stig, projected_nist, + requested_at, reviewed_at` + +// listCols adds the joined hostname for the list query. Aliased "e." since the +// list query joins hosts as h. +const listCols = `e.id, e.host_id, COALESCE(h.hostname, ''), e.rule_id, e.status, + e.requested_by, e.reviewed_by, COALESCE(e.review_note, ''), e.scan_run_id, + COALESCE(e.mechanism, ''), e.reboot_required, e.transactional, + e.projected_cis, e.projected_stig, e.projected_nist, + e.requested_at, e.reviewed_at` + +func scanRequest(row pgx.Row) (Request, error) { + var rq Request + var status string + if err := row.Scan(&rq.ID, &rq.HostID, &rq.RuleID, &status, &rq.RequestedBy, + &rq.ReviewedBy, &rq.ReviewNote, &rq.ScanRunID, &rq.Mechanism, + &rq.RebootRequired, &rq.Transactional, + &rq.Projected.CIS, &rq.Projected.STIG, &rq.Projected.NIST, + &rq.RequestedAt, &rq.ReviewedAt); err != nil { + return Request{}, err + } + rq.Status = Status(status) + return rq, nil +} + +// scanListRequest scans a list row (listCols), including hostname. +func scanListRequest(row pgx.Row) (Request, error) { + var rq Request + var status string + if err := row.Scan(&rq.ID, &rq.HostID, &rq.HostName, &rq.RuleID, &status, + &rq.RequestedBy, &rq.ReviewedBy, &rq.ReviewNote, &rq.ScanRunID, &rq.Mechanism, + &rq.RebootRequired, &rq.Transactional, + &rq.Projected.CIS, &rq.Projected.STIG, &rq.Projected.NIST, + &rq.RequestedAt, &rq.ReviewedAt); err != nil { + return Request{}, err + } + rq.Status = Status(status) + return rq, nil +} + +// Request submits a new remediation request (status 'pending_approval'), +// recording a best-effort projected per-framework lift. Returns +// ErrDuplicateOpen when an open request already exists for the same host+rule. +// NEVER contacts the host. Emits remediation.requested. +func (s *Service) Request(ctx context.Context, hostID uuid.UUID, ruleID string, + scanRunID *uuid.UUID, requestedBy uuid.UUID) (Request, error) { + ruleID = strings.TrimSpace(ruleID) + if ruleID == "" { + return Request{}, ErrInvalidInput + } + + // Best-effort projection: a failure to compute lift must not block the + // request (C-07). An empty projection is recorded as NULL columns. + proj, _ := s.ProjectLift(ctx, hostID, ruleID) + + id := uuid.Must(uuid.NewV7()) + row := s.pool.QueryRow(ctx, ` + INSERT INTO remediation_requests + (id, host_id, rule_id, scan_run_id, status, requested_by, + projected_cis, projected_stig, projected_nist) + VALUES ($1, $2, $3, $4, 'pending_approval', $5, $6, $7, $8) + RETURNING `+selectCols, + id, hostID, ruleID, scanRunID, requestedBy, proj.CIS, proj.STIG, proj.NIST) + rq, err := scanRequest(row) + if err != nil { + if isUniqueViolation(err) { + return Request{}, ErrDuplicateOpen + } + return Request{}, fmt.Errorf("remediation: request: %w", err) + } + + s.emitEvent(ctx, audit.RemediationRequested, rq, requestedBy, "requested") + return rq, nil +} + +// Approve transitions a 'pending_approval' request to 'approved'. The reviewer +// must differ from the requester (separation of duties). Emits +// remediation.approved. +func (s *Service) Approve(ctx context.Context, id, reviewedBy uuid.UUID, note string) (Request, error) { + return s.review(ctx, id, reviewedBy, note, StatusPendingApproval, StatusApproved) +} + +// Reject transitions a 'pending_approval' request to 'rejected'. Like Approve, +// the reviewer must differ from the requester. The registered taxonomy has no +// separate rejected code, so this emits remediation.approved with +// detail.outcome=rejected. +func (s *Service) Reject(ctx context.Context, id, reviewedBy uuid.UUID, note string) (Request, error) { + return s.review(ctx, id, reviewedBy, note, StatusPendingApproval, StatusRejected) +} + +// review performs a guarded state transition fromState -> toState under a row +// lock (FOR UPDATE) so concurrent reviewers cannot double-transition. Both +// approve and reject emit remediation.approved (the only registered review +// code); the outcome is carried in detail.outcome. +func (s *Service) review(ctx context.Context, id, reviewedBy uuid.UUID, note string, + fromState, toState Status) (Request, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return Request{}, fmt.Errorf("remediation: review begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Lock the row and read the requester for the self-review check. + var status string + var requestedBy uuid.UUID + err = tx.QueryRow(ctx, ` + SELECT status, requested_by FROM remediation_requests + WHERE id = $1 FOR UPDATE`, id).Scan(&status, &requestedBy) + if errors.Is(err, pgx.ErrNoRows) { + return Request{}, ErrNotFound + } + if err != nil { + return Request{}, fmt.Errorf("remediation: review lock: %w", err) + } + if Status(status) != fromState { + return Request{}, ErrWrongState + } + if requestedBy == reviewedBy { + return Request{}, ErrSelfReview + } + + row := tx.QueryRow(ctx, ` + UPDATE remediation_requests + SET status = $2, reviewed_by = $3, review_note = NULLIF($4, ''), + reviewed_at = now(), updated_at = now() + WHERE id = $1 + RETURNING `+selectCols, + id, string(toState), reviewedBy, note) + rq, err := scanRequest(row) + if err != nil { + return Request{}, fmt.Errorf("remediation: review update: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return Request{}, fmt.Errorf("remediation: review commit: %w", err) + } + + s.emitEvent(ctx, audit.RemediationApproved, rq, reviewedBy, string(toState)) + return rq, nil +} + +// Get returns a single remediation request by id, or ErrNotFound. +func (s *Service) Get(ctx context.Context, id uuid.UUID) (Request, error) { + row := s.pool.QueryRow(ctx, `SELECT `+selectCols+` + FROM remediation_requests WHERE id = $1`, id) + rq, err := scanRequest(row) + if errors.Is(err, pgx.ErrNoRows) { + return Request{}, ErrNotFound + } + if err != nil { + return Request{}, fmt.Errorf("remediation: get: %w", err) + } + return rq, nil +} + +// ListFilter scopes ListRequests. A zero-value filter lists the whole fleet. +type ListFilter struct { + Status Status + HostID *uuid.UUID + RuleID string + Limit int +} + +// ListRequests returns remediation requests, newest first, optionally filtered +// by status, host, or rule. Soft-deleted hosts are excluded. +func (s *Service) ListRequests(ctx context.Context, f ListFilter) ([]Request, error) { + limit := f.Limit + if limit <= 0 || limit > 500 { + limit = 200 + } + q := `SELECT ` + listCols + ` FROM remediation_requests e + JOIN hosts h ON h.id = e.host_id AND h.deleted_at IS NULL WHERE 1 = 1` + args := []any{} + if f.Status != "" { + args = append(args, string(f.Status)) + q += fmt.Sprintf(" AND e.status = $%d", len(args)) + } + if f.HostID != nil { + args = append(args, *f.HostID) + q += fmt.Sprintf(" AND e.host_id = $%d", len(args)) + } + if r := strings.TrimSpace(f.RuleID); r != "" { + args = append(args, r) + q += fmt.Sprintf(" AND e.rule_id = $%d", len(args)) + } + args = append(args, limit) + q += fmt.Sprintf(" ORDER BY e.requested_at DESC LIMIT $%d", len(args)) + + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("remediation: list: %w", err) + } + defer rows.Close() + out := []Request{} + for rows.Next() { + rq, err := scanListRequest(rows) + if err != nil { + return nil, fmt.Errorf("remediation: scan: %w", err) + } + out = append(out, rq) + } + return out, rows.Err() +} + +// ListSteps returns the per-step Kensa transaction journal for a request, in +// apply order. Empty in the free build (only the licensed execute path writes +// steps). +func (s *Service) ListSteps(ctx context.Context, requestID uuid.UUID) ([]Step, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, rule_id, COALESCE(mechanism, ''), phase_result, dry_run, applied_at + FROM remediation_transactions + WHERE request_id = $1 + ORDER BY ordinal ASC, created_at ASC`, requestID) + if err != nil { + return nil, fmt.Errorf("remediation: list steps: %w", err) + } + defer rows.Close() + out := []Step{} + for rows.Next() { + var st Step + if err := rows.Scan(&st.ID, &st.RuleID, &st.Mechanism, &st.PhaseResult, + &st.DryRun, &st.AppliedAt); err != nil { + return nil, fmt.Errorf("remediation: scan step: %w", err) + } + out = append(out, st) + } + return out, rows.Err() +} + +// ProjectLift estimates the per-framework compliance-score lift (percentage +// points) if ruleID flips to pass on hostID. Read-only and best-effort: it +// reads host_rule_state only. A non-failing or unknown rule, or absent +// framework data, yields an empty projection (no error). One passing rule is +// ~1/N of its framework's rules on the host, so delta ~= 100/N. +func (s *Service) ProjectLift(ctx context.Context, hostID uuid.UUID, ruleID string) (ProjectedLift, error) { + var status string + var refsRaw []byte + err := s.pool.QueryRow(ctx, ` + SELECT current_status, framework_refs FROM host_rule_state + WHERE host_id = $1 AND rule_id = $2`, hostID, ruleID).Scan(&status, &refsRaw) + if errors.Is(err, pgx.ErrNoRows) { + return ProjectedLift{}, nil // no state for this rule on this host + } + if err != nil { + return ProjectedLift{}, fmt.Errorf("remediation: project lift: %w", err) + } + if status != "fail" { + return ProjectedLift{}, nil // only a failing rule has lift to gain + } + + refs := map[string][]string{} + _ = json.Unmarshal(refsRaw, &refs) + classes := map[string]bool{} + for fwID := range refs { + if c := frameworkClass(fwID); c != "" { + classes[c] = true + } + } + if len(classes) == 0 { + return ProjectedLift{}, nil + } + + // Denominators: how many of the host's rules participate in each framework. + var nCIS, nSTIG, nNIST int + err = s.pool.QueryRow(ctx, ` + SELECT + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM jsonb_object_keys(framework_refs) k WHERE k LIKE 'cis%')), + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM jsonb_object_keys(framework_refs) k WHERE k LIKE 'stig%')), + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM jsonb_object_keys(framework_refs) k WHERE k LIKE 'nist%')) + FROM host_rule_state WHERE host_id = $1`, hostID).Scan(&nCIS, &nSTIG, &nNIST) + if err != nil { + return ProjectedLift{}, fmt.Errorf("remediation: project lift denom: %w", err) + } + + var out ProjectedLift + if classes["cis"] && nCIS > 0 { + out.CIS = f64ptr(round2(100.0 / float64(nCIS))) + } + if classes["stig"] && nSTIG > 0 { + out.STIG = f64ptr(round2(100.0 / float64(nSTIG))) + } + if classes["nist"] && nNIST > 0 { + out.NIST = f64ptr(round2(100.0 / float64(nNIST))) + } + return out, nil +} + +// frameworkClass maps a kensa framework_id (e.g. "cis_rhel9_v2", +// "stig_rhel9_v2r7", "nist_800_53_r5") to the cis/stig/nist projection bucket. +func frameworkClass(fwID string) string { + switch { + case strings.HasPrefix(fwID, "cis"): + return "cis" + case strings.HasPrefix(fwID, "stig"): + return "stig" + case strings.HasPrefix(fwID, "nist"): + return "nist" + } + return "" +} + +func round2(f float64) float64 { return math.Round(f*100) / 100 } +func f64ptr(f float64) *float64 { return &f } + +// emitEvent records one remediation.* audit row. actor is the +// requester/reviewer. +func (s *Service) emitEvent(ctx context.Context, code audit.Code, rq Request, actor uuid.UUID, outcome string) { + if s.emit == nil { + return + } + detail, _ := json.Marshal(map[string]any{ + "request_id": rq.ID.String(), + "host_id": rq.HostID.String(), + "rule_id": rq.RuleID, + "outcome": outcome, + "status": string(rq.Status), + }) + s.emit(ctx, code, audit.Event{ + ActorType: "user", + ActorID: actor.String(), + ResourceType: "remediation_request", + ResourceID: rq.ID.String(), + Detail: detail, + }) +} + +// isUniqueViolation reports whether err is a Postgres unique-violation +// (SQLSTATE 23505) - the partial-unique one-open-per-host+rule index. +func isUniqueViolation(err error) bool { + var pgErr interface{ SQLState() string } + if errors.As(err, &pgErr) { + return pgErr.SQLState() == "23505" + } + return false +} diff --git a/internal/remediation/service_test.go b/internal/remediation/service_test.go new file mode 100644 index 00000000..5c04ca1c --- /dev/null +++ b/internal/remediation/service_test.go @@ -0,0 +1,285 @@ +// @spec api-remediation +// +// Service-level AC coverage (DSN-gated). Endpoint AC-05 and the license-gate +// AC-06 live in internal/server. +// +// AC-01 TestRequest_InsertDuplicateInvalidReopen +// AC-02 TestLifecycle_Transitions +// AC-03 TestSeparationOfDuties +// AC-04 TestProjectLift_AndOverlayNeverMutatesRuleState +package remediation + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Hanalyx/openwatch/internal/audit" + "github.com/Hanalyx/openwatch/internal/db/dbtest" +) + +type emitCall struct { + Code audit.Code + Detail string +} + +func fakeEmitter(calls *[]emitCall) EmitFunc { + return func(ctx context.Context, code audit.Code, ev audit.Event) { + *calls = append(*calls, emitCall{Code: code, Detail: string(ev.Detail)}) + } +} + +func freshPool(t *testing.T) *pgxpool.Pool { + t.Helper() + pool := dbtest.Pool(t) + ctx := context.Background() + for _, stmt := range []string{ + "TRUNCATE TABLE remediation_transactions CASCADE", + "TRUNCATE TABLE remediation_requests CASCADE", + "TRUNCATE TABLE host_rule_state CASCADE", + "TRUNCATE TABLE hosts CASCADE", + "TRUNCATE TABLE users CASCADE", + } { + if _, err := pool.Exec(ctx, stmt); err != nil { + t.Logf("truncate (ok if benign): %v", err) + } + } + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) uuid.UUID { + t.Helper() + id, _ := uuid.NewV7() + _, err := pool.Exec(context.Background(), + `INSERT INTO users (id, username, email, password_hash) + VALUES ($1, $2, $3, $4)`, + id, name+"-"+id.String(), name+"@example.com", "argon2id$dummy") // pragma: allowlist secret + if err != nil { + t.Fatalf("seed user: %v", err) + } + return id +} + +func seedHost(t *testing.T, pool *pgxpool.Pool, createdBy uuid.UUID) uuid.UUID { + t.Helper() + id, _ := uuid.NewV7() + _, err := pool.Exec(context.Background(), + `INSERT INTO hosts (id, hostname, ip_address, created_by) + VALUES ($1, $2, '192.0.2.40'::inet, $3)`, + id, "rem-"+id.String(), createdBy) + if err != nil { + t.Fatalf("seed host: %v", err) + } + return id +} + +// seedRuleState inserts one host_rule_state row with the given status and +// framework_refs JSON (e.g. `{"cis_rhel9_v2":["1.1"]}`). +func seedRuleState(t *testing.T, pool *pgxpool.Pool, hostID uuid.UUID, ruleID, status, refsJSON string) { + t.Helper() + _, err := pool.Exec(context.Background(), ` + INSERT INTO host_rule_state + (host_id, rule_id, current_status, severity, last_checked_at, + check_count, last_scan_id, evidence, framework_refs, first_seen_at, last_changed_at) + VALUES ($1, $2, $3, 'high', now(), 1, $4, '{}'::jsonb, $5::jsonb, now(), now())`, + hostID, ruleID, status, uuid.Must(uuid.NewV7()), refsJSON) + if err != nil { + t.Fatalf("seed rule state %s: %v", ruleID, err) + } +} + +// @ac AC-01 +func TestRequest_InsertDuplicateInvalidReopen(t *testing.T) { + t.Run("api-remediation/AC-01", func(t *testing.T) { + pool := freshPool(t) + ctx := context.Background() + user := seedUser(t, pool, "requester") + hostID := seedHost(t, pool, user) + var calls []emitCall + svc := NewService(pool, fakeEmitter(&calls)) + + rq, err := svc.Request(ctx, hostID, "sshd-permit-root-no", nil, user) + if err != nil { + t.Fatalf("Request: %v", err) + } + if rq.Status != StatusPendingApproval || rq.RuleID != "sshd-permit-root-no" { + t.Errorf("requested remediation = %+v", rq) + } + if len(calls) != 1 || calls[0].Code != audit.RemediationRequested { + t.Errorf("audit calls = %+v, want one requested", calls) + } + + // Duplicate open: rejected. + if _, err := svc.Request(ctx, hostID, "sshd-permit-root-no", nil, user); !errors.Is(err, ErrDuplicateOpen) { + t.Errorf("duplicate Request err = %v, want ErrDuplicateOpen", err) + } + + // Invalid input (empty rule). + if _, err := svc.Request(ctx, hostID, " ", nil, user); !errors.Is(err, ErrInvalidInput) { + t.Errorf("empty rule err = %v, want ErrInvalidInput", err) + } + + // Reopen after the prior is rejected: a fresh request succeeds. + reviewer := seedUser(t, pool, "reviewer") + if _, err := svc.Reject(ctx, rq.ID, reviewer, "not now"); err != nil { + t.Fatalf("Reject: %v", err) + } + if _, err := svc.Request(ctx, hostID, "sshd-permit-root-no", nil, user); err != nil { + t.Errorf("reopen after reject failed: %v", err) + } + }) +} + +// @ac AC-02 +func TestLifecycle_Transitions(t *testing.T) { + t.Run("api-remediation/AC-02", func(t *testing.T) { + pool := freshPool(t) + ctx := context.Background() + requester := seedUser(t, pool, "req") + reviewer := seedUser(t, pool, "rev") + hostID := seedHost(t, pool, requester) + var calls []emitCall + svc := NewService(pool, fakeEmitter(&calls)) + + // approve path + a, _ := svc.Request(ctx, hostID, "rule-a", nil, requester) + got, err := svc.Approve(ctx, a.ID, reviewer, "ok") + if err != nil || got.Status != StatusApproved || got.ReviewedBy == nil || *got.ReviewedBy != reviewer { + t.Fatalf("Approve = %+v, err %v", got, err) + } + // approving an already-approved row: wrong state + if _, err := svc.Approve(ctx, a.ID, reviewer, "again"); !errors.Is(err, ErrWrongState) { + t.Errorf("re-approve err = %v, want ErrWrongState", err) + } + + // reject path + b, _ := svc.Request(ctx, hostID, "rule-b", nil, requester) + if got, err := svc.Reject(ctx, b.ID, reviewer, "no"); err != nil || got.Status != StatusRejected { + t.Fatalf("Reject = %+v, err %v", got, err) + } + // approving a rejected row: wrong state + if _, err := svc.Approve(ctx, b.ID, reviewer, "x"); !errors.Is(err, ErrWrongState) { + t.Errorf("approve-rejected err = %v, want ErrWrongState", err) + } + + // Reject emits remediation.approved with detail.outcome=rejected (no + // separate rejected code in the registered taxonomy). + seen := map[audit.Code]int{} + var rejectDetail string + for _, c := range calls { + seen[c.Code]++ + if c.Code == audit.RemediationApproved && strings.Contains(c.Detail, `"outcome":"rejected"`) { + rejectDetail = c.Detail + } + } + if seen[audit.RemediationApproved] != 2 { + t.Errorf("approved-code count = %d, want 2 (approve + reject)", seen[audit.RemediationApproved]) + } + if rejectDetail == "" { + t.Errorf("reject did not emit remediation.approved with outcome=rejected; calls=%+v", calls) + } + // missing-row transition -> ErrNotFound + if _, err := svc.Approve(ctx, uuid.Must(uuid.NewV7()), reviewer, ""); !errors.Is(err, ErrNotFound) { + t.Errorf("approve-missing err = %v, want ErrNotFound", err) + } + }) +} + +// @ac AC-03 +func TestSeparationOfDuties(t *testing.T) { + t.Run("api-remediation/AC-03", func(t *testing.T) { + pool := freshPool(t) + ctx := context.Background() + user := seedUser(t, pool, "selfreq") + hostID := seedHost(t, pool, user) + svc := NewService(pool, fakeEmitter(&[]emitCall{})) + + rq, _ := svc.Request(ctx, hostID, "rule-s", nil, user) + // self-approve blocked + if _, err := svc.Approve(ctx, rq.ID, user, "me"); !errors.Is(err, ErrSelfReview) { + t.Errorf("self-approve err = %v, want ErrSelfReview", err) + } + // self-reject blocked + if _, err := svc.Reject(ctx, rq.ID, user, "me"); !errors.Is(err, ErrSelfReview) { + t.Errorf("self-reject err = %v, want ErrSelfReview", err) + } + // row unchanged (still pending_approval) + got, _ := svc.Get(ctx, rq.ID) + if got.Status != StatusPendingApproval { + t.Errorf("row mutated by blocked self-review: %+v", got) + } + }) +} + +// @ac AC-04 +func TestProjectLift_AndOverlayNeverMutatesRuleState(t *testing.T) { + t.Run("api-remediation/AC-04", func(t *testing.T) { + pool := freshPool(t) + ctx := context.Background() + requester := seedUser(t, pool, "req") + reviewer := seedUser(t, pool, "rev") + hostID := seedHost(t, pool, requester) + svc := NewService(pool, fakeEmitter(&[]emitCall{})) + + // Framework participation on the host: + // cis : active, b, c -> N=3 -> delta 33.33 + // stig: active, e -> N=2 -> delta 50.0 + // nist: d -> N=1 (active does not map nist) + seedRuleState(t, pool, hostID, "rule-active", "fail", `{"cis_rhel9_v2":["1.1"],"stig_rhel9_v2r7":["V-1"]}`) + seedRuleState(t, pool, hostID, "rule-b", "pass", `{"cis_rhel9_v2":["1.2"]}`) + seedRuleState(t, pool, hostID, "rule-c", "fail", `{"cis_rhel9_v2":["1.3"]}`) + seedRuleState(t, pool, hostID, "rule-d", "fail", `{"nist_800_53_r5":["AC-6"]}`) + seedRuleState(t, pool, hostID, "rule-e", "pass", `{"stig_rhel9_v2r7":["V-2"]}`) + + // ProjectLift for the failing, cis+stig-mapped rule. + lift, err := svc.ProjectLift(ctx, hostID, "rule-active") + if err != nil { + t.Fatalf("ProjectLift: %v", err) + } + if lift.CIS == nil || *lift.CIS != 33.33 { + t.Errorf("CIS lift = %v, want 33.33", lift.CIS) + } + if lift.STIG == nil || *lift.STIG != 50.0 { + t.Errorf("STIG lift = %v, want 50.0", lift.STIG) + } + if lift.NIST != nil { + t.Errorf("NIST lift = %v, want nil (rule does not map nist)", lift.NIST) + } + + // A passing rule has no lift to gain. + if l, _ := svc.ProjectLift(ctx, hostID, "rule-b"); l.CIS != nil || l.STIG != nil || l.NIST != nil { + t.Errorf("passing-rule lift = %+v, want empty", l) + } + // An unknown rule degrades to an empty projection (no error). + if l, err := svc.ProjectLift(ctx, hostID, "rule-unknown"); err != nil || l.CIS != nil { + t.Errorf("unknown-rule lift = %+v err %v, want empty/no-error", l, err) + } + + // Request persists the projection snapshot. + rq, err := svc.Request(ctx, hostID, "rule-active", nil, requester) + if err != nil { + t.Fatalf("Request: %v", err) + } + if rq.Projected.CIS == nil || *rq.Projected.CIS != 33.33 { + t.Errorf("persisted CIS projection = %v, want 33.33", rq.Projected.CIS) + } + _, _ = svc.Approve(ctx, rq.ID, reviewer, "ok") + + // Overlay invariant: no remediation path mutated host_rule_state, and + // the journal stays empty in the free build. + var status string + _ = pool.QueryRow(ctx, `SELECT current_status FROM host_rule_state + WHERE host_id = $1 AND rule_id = 'rule-active'`, hostID).Scan(&status) + if status != "fail" { + t.Errorf("remediation mutated host_rule_state: status = %q, want fail", status) + } + steps, _ := svc.ListSteps(ctx, rq.ID) + if len(steps) != 0 { + t.Errorf("free build wrote %d journal steps, want 0", len(steps)) + } + }) +} diff --git a/internal/remediation/types.go b/internal/remediation/types.go new file mode 100644 index 00000000..11322c1b --- /dev/null +++ b/internal/remediation/types.go @@ -0,0 +1,98 @@ +// Package remediation implements the free (AGPLv3 core) half of Phase 7 +// remediation governance: an operator's intent to fix a failing rule on a +// host, with a request -> approve | reject lifecycle and a read-only +// projected-lift estimate. +// +// FREE-PATH INVARIANT (load-bearing): nothing in this package contacts a host +// or mutates host_rule_state / transactions. Request, Approve, and Reject are +// pure state transitions over remediation_requests; ProjectLift only reads +// host_rule_state. The act of mutating a host (dry-run / execute / rollback) +// is the OpenWatch+ licensed track, gated by the remediation_execution license +// feature, and is NOT implemented here. +// +// Separation of duties: the requester cannot review their own request +// (enforced here, on top of the distinct remediation:request vs +// remediation:approve RBAC permissions). +// +// Spec: api-remediation v1.0.0. Plan: docs/engineering/remediation_core_plan.md. +package remediation + +import ( + "errors" + "time" + + "github.com/google/uuid" +) + +// Status is the remediation request lifecycle state. The free path uses +// pending_approval -> approved | rejected; the remaining states are driven by +// the OpenWatch+ licensed execution track. +type Status string + +const ( + StatusPendingApproval Status = "pending_approval" + StatusApproved Status = "approved" + StatusRejected Status = "rejected" + StatusDryRunComplete Status = "dry_run_complete" + StatusExecuting Status = "executing" + StatusExecuted Status = "executed" + StatusRolledBack Status = "rolled_back" + StatusFailed Status = "failed" +) + +// ProjectedLift is the estimated per-framework compliance-score delta +// (percentage points) if the rule flips to pass. A nil field means that +// framework's data was unavailable for the host (best-effort projection). +type ProjectedLift struct { + CIS *float64 + STIG *float64 + NIST *float64 +} + +// Request is one remediation_requests row. +type Request struct { + ID uuid.UUID + HostID uuid.UUID + // HostName is populated by the list query via a join; the single-row + // lifecycle ops leave it empty (the UI re-fetches the list after a mutation). + HostName string + RuleID string + Status Status + RequestedBy uuid.UUID + ReviewedBy *uuid.UUID + ReviewNote string + ScanRunID *uuid.UUID + Mechanism string + RebootRequired bool + Transactional bool + Projected ProjectedLift + RequestedAt time.Time + ReviewedAt *time.Time +} + +// Step is one remediation_transactions row (the per-step Kensa journal). +// Written only by the licensed execute path; empty in the free build. +type Step struct { + ID uuid.UUID + RuleID string + Mechanism string + PhaseResult *string + DryRun bool + AppliedAt *time.Time +} + +var ( + // ErrNotFound is returned when a remediation request id does not exist. + ErrNotFound = errors.New("remediation: not found") + // ErrDuplicateOpen is returned when an open request already exists for the + // same host+rule (partial-unique violation). + ErrDuplicateOpen = errors.New("remediation: an open remediation request already exists for this host and rule") + // ErrWrongState is returned when a transition does not apply to the current + // status (e.g. approving an already-rejected request). + ErrWrongState = errors.New("remediation: action not valid for the current state") + // ErrSelfReview is returned when the reviewer is the requester: + // separation of duties forbids approving your own request. + ErrSelfReview = errors.New("remediation: requester cannot review their own request") + // ErrInvalidInput is returned for an empty rule_id. + ErrInvalidInput = errors.New("remediation: invalid input") +) diff --git a/internal/server/api/server.gen.go b/internal/server/api/server.gen.go index 665f2b48..878c56e9 100644 --- a/internal/server/api/server.gen.go +++ b/internal/server/api/server.gen.go @@ -371,22 +371,22 @@ func (e FleetTransactionChangeKind) Valid() bool { // Defines values for FleetTransactionStatus. const ( - Error FleetTransactionStatus = "error" - Fail FleetTransactionStatus = "fail" - Pass FleetTransactionStatus = "pass" - Skipped FleetTransactionStatus = "skipped" + FleetTransactionStatusError FleetTransactionStatus = "error" + FleetTransactionStatusFail FleetTransactionStatus = "fail" + FleetTransactionStatusPass FleetTransactionStatus = "pass" + FleetTransactionStatusSkipped FleetTransactionStatus = "skipped" ) // Valid indicates whether the value is a known member of the FleetTransactionStatus enum. func (e FleetTransactionStatus) Valid() bool { switch e { - case Error: + case FleetTransactionStatusError: return true - case Fail: + case FleetTransactionStatusFail: return true - case Pass: + case FleetTransactionStatusPass: return true - case Skipped: + case FleetTransactionStatusSkipped: return true default: return false @@ -798,6 +798,63 @@ func (e NotificationChannelCreateType) Valid() bool { } } +// Defines values for RemediationRequestStatus. +const ( + RemediationRequestStatusApproved RemediationRequestStatus = "approved" + RemediationRequestStatusDryRunComplete RemediationRequestStatus = "dry_run_complete" + RemediationRequestStatusExecuted RemediationRequestStatus = "executed" + RemediationRequestStatusExecuting RemediationRequestStatus = "executing" + RemediationRequestStatusFailed RemediationRequestStatus = "failed" + RemediationRequestStatusPendingApproval RemediationRequestStatus = "pending_approval" + RemediationRequestStatusRejected RemediationRequestStatus = "rejected" + RemediationRequestStatusRolledBack RemediationRequestStatus = "rolled_back" +) + +// Valid indicates whether the value is a known member of the RemediationRequestStatus enum. +func (e RemediationRequestStatus) Valid() bool { + switch e { + case RemediationRequestStatusApproved: + return true + case RemediationRequestStatusDryRunComplete: + return true + case RemediationRequestStatusExecuted: + return true + case RemediationRequestStatusExecuting: + return true + case RemediationRequestStatusFailed: + return true + case RemediationRequestStatusPendingApproval: + return true + case RemediationRequestStatusRejected: + return true + case RemediationRequestStatusRolledBack: + return true + default: + return false + } +} + +// Defines values for RemediationStepPhaseResult. +const ( + RemediationStepPhaseResultCommitted RemediationStepPhaseResult = "committed" + RemediationStepPhaseResultRolledBack RemediationStepPhaseResult = "rolled_back" + RemediationStepPhaseResultSkipped RemediationStepPhaseResult = "skipped" +) + +// Valid indicates whether the value is a known member of the RemediationStepPhaseResult enum. +func (e RemediationStepPhaseResult) Valid() bool { + switch e { + case RemediationStepPhaseResultCommitted: + return true + case RemediationStepPhaseResultRolledBack: + return true + case RemediationStepPhaseResultSkipped: + return true + default: + return false + } +} + // Defines values for ReportKind. const ( Executive ReportKind = "executive" @@ -990,6 +1047,42 @@ func (e GetIntelligenceEventsParamsSeverity) Valid() bool { } } +// Defines values for ListRemediationRequestsParamsStatus. +const ( + ListRemediationRequestsParamsStatusApproved ListRemediationRequestsParamsStatus = "approved" + ListRemediationRequestsParamsStatusDryRunComplete ListRemediationRequestsParamsStatus = "dry_run_complete" + ListRemediationRequestsParamsStatusExecuted ListRemediationRequestsParamsStatus = "executed" + ListRemediationRequestsParamsStatusExecuting ListRemediationRequestsParamsStatus = "executing" + ListRemediationRequestsParamsStatusFailed ListRemediationRequestsParamsStatus = "failed" + ListRemediationRequestsParamsStatusPendingApproval ListRemediationRequestsParamsStatus = "pending_approval" + ListRemediationRequestsParamsStatusRejected ListRemediationRequestsParamsStatus = "rejected" + ListRemediationRequestsParamsStatusRolledBack ListRemediationRequestsParamsStatus = "rolled_back" +) + +// Valid indicates whether the value is a known member of the ListRemediationRequestsParamsStatus enum. +func (e ListRemediationRequestsParamsStatus) Valid() bool { + switch e { + case ListRemediationRequestsParamsStatusApproved: + return true + case ListRemediationRequestsParamsStatusDryRunComplete: + return true + case ListRemediationRequestsParamsStatusExecuted: + return true + case ListRemediationRequestsParamsStatusExecuting: + return true + case ListRemediationRequestsParamsStatusFailed: + return true + case ListRemediationRequestsParamsStatusPendingApproval: + return true + case ListRemediationRequestsParamsStatusRejected: + return true + case ListRemediationRequestsParamsStatusRolledBack: + return true + default: + return false + } +} + // Activity defines model for Activity. type Activity struct { HostId *openapi_types.UUID `json:"host_id,omitempty"` @@ -2294,6 +2387,79 @@ type PolicyDecisionResponse struct { Reason string `json:"reason"` } +// ProjectedLift Estimated per-framework compliance-score delta (percentage points) if the rule flips to pass. Best-effort; a field is null when that framework's data is unavailable for the host. +type ProjectedLift struct { + Cis *float64 `json:"cis,omitempty"` + Nist *float64 `json:"nist,omitempty"` + Stig *float64 `json:"stig,omitempty"` +} + +// RemediationRequest defines model for RemediationRequest. +type RemediationRequest struct { + HostId openapi_types.UUID `json:"host_id"` + + // HostName Hostname, populated on list responses (empty on single-row lifecycle results) + HostName *string `json:"host_name,omitempty"` + Id openapi_types.UUID `json:"id"` + + // Mechanism Kensa remediation handler id (empty when unknown at request time) + Mechanism *string `json:"mechanism,omitempty"` + + // ProjectedLift Estimated per-framework compliance-score delta (percentage points) if the rule flips to pass. Best-effort; a field is null when that framework's data is unavailable for the host. + ProjectedLift *ProjectedLift `json:"projected_lift,omitempty"` + RebootRequired *bool `json:"reboot_required,omitempty"` + RequestedAt time.Time `json:"requested_at"` + RequestedBy openapi_types.UUID `json:"requested_by"` + ReviewNote *string `json:"review_note,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + ReviewedBy *openapi_types.UUID `json:"reviewed_by,omitempty"` + RuleId string `json:"rule_id"` + ScanRunId *openapi_types.UUID `json:"scan_run_id,omitempty"` + Status RemediationRequestStatus `json:"status"` + Transactional *bool `json:"transactional,omitempty"` +} + +// RemediationRequestStatus defines model for RemediationRequest.Status. +type RemediationRequestStatus string + +// RemediationRequestCreate defines model for RemediationRequestCreate. +type RemediationRequestCreate struct { + HostId openapi_types.UUID `json:"host_id"` + RuleId string `json:"rule_id"` + + // ScanRunId Optional provenance - the scan run whose finding this remediates + ScanRunId *openapi_types.UUID `json:"scan_run_id,omitempty"` +} + +// RemediationRequestList defines model for RemediationRequestList. +type RemediationRequestList struct { + Requests []RemediationRequest `json:"requests"` +} + +// RemediationReview defines model for RemediationReview. +type RemediationReview struct { + // Note Optional reviewer note + Note *string `json:"note,omitempty"` +} + +// RemediationStep defines model for RemediationStep. +type RemediationStep struct { + AppliedAt *time.Time `json:"applied_at,omitempty"` + DryRun bool `json:"dry_run"` + Id openapi_types.UUID `json:"id"` + Mechanism *string `json:"mechanism,omitempty"` + PhaseResult *RemediationStepPhaseResult `json:"phase_result,omitempty"` + RuleId string `json:"rule_id"` +} + +// RemediationStepPhaseResult defines model for RemediationStep.PhaseResult. +type RemediationStepPhaseResult string + +// RemediationStepList defines model for RemediationStepList. +type RemediationStepList struct { + Steps []RemediationStep `json:"steps"` +} + // Report defines model for Report. type Report struct { // Content The rendered JSON posture document. For an executive summary: @@ -2894,6 +3060,17 @@ type GetIntelligenceEventsParams struct { // GetIntelligenceEventsParamsSeverity defines parameters for GetIntelligenceEvents. type GetIntelligenceEventsParamsSeverity string +// ListRemediationRequestsParams defines parameters for ListRemediationRequests. +type ListRemediationRequestsParams struct { + Status *ListRemediationRequestsParamsStatus `form:"status,omitempty" json:"status,omitempty"` + HostId *openapi_types.UUID `form:"host_id,omitempty" json:"host_id,omitempty"` + RuleId *string `form:"rule_id,omitempty" json:"rule_id,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` +} + +// ListRemediationRequestsParamsStatus defines parameters for ListRemediationRequests. +type ListRemediationRequestsParamsStatus string + // PostReportGenerateJSONBody defines parameters for PostReportGenerate. type PostReportGenerateJSONBody = map[string]interface{} @@ -3002,6 +3179,15 @@ type PostNotificationChannelJSONRequestBody = NotificationChannelCreate // PatchNotificationChannelJSONRequestBody defines body for PatchNotificationChannel for application/json ContentType. type PatchNotificationChannelJSONRequestBody = NotificationChannelUpdate +// RequestRemediationJSONRequestBody defines body for RequestRemediation for application/json ContentType. +type RequestRemediationJSONRequestBody = RemediationRequestCreate + +// ApproveRemediationJSONRequestBody defines body for ApproveRemediation for application/json ContentType. +type ApproveRemediationJSONRequestBody = RemediationReview + +// RejectRemediationJSONRequestBody defines body for RejectRemediation for application/json ContentType. +type RejectRemediationJSONRequestBody = RemediationReview + // PostReportGenerateJSONRequestBody defines body for PostReportGenerate for application/json ContentType. type PostReportGenerateJSONRequestBody = PostReportGenerateJSONBody @@ -3301,6 +3487,33 @@ type ServerInterface interface { // Send a synthetic test alert through the channel // (POST /api/v1/notifications/channels/{id}:test) TestNotificationChannel(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // List remediation requests (fleet or filtered) + // (GET /api/v1/remediation/requests) + ListRemediationRequests(w http.ResponseWriter, r *http.Request, params ListRemediationRequestsParams) + // Request a remediation (fix) for a failing rule on a host + // (POST /api/v1/remediation/requests) + RequestRemediation(w http.ResponseWriter, r *http.Request) + // Get a remediation request + // (GET /api/v1/remediation/requests/{rid}) + GetRemediationRequest(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) + // List a remediation request's transaction journal (steps) + // (GET /api/v1/remediation/requests/{rid}/steps) + ListRemediationSteps(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) + // Approve a pending remediation request + // (POST /api/v1/remediation/requests/{rid}:approve) + ApproveRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) + // Dry-run a remediation (OpenWatch+) + // (POST /api/v1/remediation/requests/{rid}:dry-run) + DryRunRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) + // Execute a remediation against the host (OpenWatch+) + // (POST /api/v1/remediation/requests/{rid}:execute) + ExecuteRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) + // Reject a pending remediation request + // (POST /api/v1/remediation/requests/{rid}:reject) + RejectRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) + // Roll back an executed remediation (OpenWatch+) + // (POST /api/v1/remediation/requests/{rid}:rollback) + RollbackRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) // List generated reports (newest first) // (GET /api/v1/reports) GetReports(w http.ResponseWriter, r *http.Request) @@ -3943,6 +4156,60 @@ func (_ Unimplemented) TestNotificationChannel(w http.ResponseWriter, r *http.Re w.WriteHeader(http.StatusNotImplemented) } +// List remediation requests (fleet or filtered) +// (GET /api/v1/remediation/requests) +func (_ Unimplemented) ListRemediationRequests(w http.ResponseWriter, r *http.Request, params ListRemediationRequestsParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Request a remediation (fix) for a failing rule on a host +// (POST /api/v1/remediation/requests) +func (_ Unimplemented) RequestRemediation(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Get a remediation request +// (GET /api/v1/remediation/requests/{rid}) +func (_ Unimplemented) GetRemediationRequest(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// List a remediation request's transaction journal (steps) +// (GET /api/v1/remediation/requests/{rid}/steps) +func (_ Unimplemented) ListRemediationSteps(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Approve a pending remediation request +// (POST /api/v1/remediation/requests/{rid}:approve) +func (_ Unimplemented) ApproveRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Dry-run a remediation (OpenWatch+) +// (POST /api/v1/remediation/requests/{rid}:dry-run) +func (_ Unimplemented) DryRunRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Execute a remediation against the host (OpenWatch+) +// (POST /api/v1/remediation/requests/{rid}:execute) +func (_ Unimplemented) ExecuteRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Reject a pending remediation request +// (POST /api/v1/remediation/requests/{rid}:reject) +func (_ Unimplemented) RejectRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Roll back an executed remediation (OpenWatch+) +// (POST /api/v1/remediation/requests/{rid}:rollback) +func (_ Unimplemented) RollbackRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + // List generated reports (newest first) // (GET /api/v1/reports) func (_ Unimplemented) GetReports(w http.ResponseWriter, r *http.Request) { @@ -6921,6 +7188,274 @@ func (siw *ServerInterfaceWrapper) TestNotificationChannel(w http.ResponseWriter handler.ServeHTTP(w, r) } +// ListRemediationRequests operation middleware +func (siw *ServerInterfaceWrapper) ListRemediationRequests(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ListRemediationRequestsParams + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "host_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "host_id", r.URL.Query(), ¶ms.HostId, runtime.BindQueryParameterOptions{Type: "string", Format: "uuid"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "host_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "host_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "rule_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "rule_id", r.URL.Query(), ¶ms.RuleId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "rule_id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rule_id", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListRemediationRequests(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RequestRemediation operation middleware +func (siw *ServerInterfaceWrapper) RequestRemediation(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RequestRemediation(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetRemediationRequest operation middleware +func (siw *ServerInterfaceWrapper) GetRemediationRequest(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "rid" ------------- + var rid openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "rid", chi.URLParam(r, "rid"), &rid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rid", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetRemediationRequest(w, r, rid) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListRemediationSteps operation middleware +func (siw *ServerInterfaceWrapper) ListRemediationSteps(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "rid" ------------- + var rid openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "rid", chi.URLParam(r, "rid"), &rid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rid", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListRemediationSteps(w, r, rid) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApproveRemediation operation middleware +func (siw *ServerInterfaceWrapper) ApproveRemediation(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "rid" ------------- + var rid openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "rid", chi.URLParam(r, "rid"), &rid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rid", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApproveRemediation(w, r, rid) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DryRunRemediation operation middleware +func (siw *ServerInterfaceWrapper) DryRunRemediation(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "rid" ------------- + var rid openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "rid", chi.URLParam(r, "rid"), &rid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rid", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DryRunRemediation(w, r, rid) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ExecuteRemediation operation middleware +func (siw *ServerInterfaceWrapper) ExecuteRemediation(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "rid" ------------- + var rid openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "rid", chi.URLParam(r, "rid"), &rid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rid", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ExecuteRemediation(w, r, rid) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RejectRemediation operation middleware +func (siw *ServerInterfaceWrapper) RejectRemediation(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "rid" ------------- + var rid openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "rid", chi.URLParam(r, "rid"), &rid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rid", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RejectRemediation(w, r, rid) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RollbackRemediation operation middleware +func (siw *ServerInterfaceWrapper) RollbackRemediation(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "rid" ------------- + var rid openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "rid", chi.URLParam(r, "rid"), &rid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "rid", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RollbackRemediation(w, r, rid) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetReports operation middleware func (siw *ServerInterfaceWrapper) GetReports(w http.ResponseWriter, r *http.Request) { @@ -8071,6 +8606,33 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/notifications/channels/{id}:test", wrapper.TestNotificationChannel) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/remediation/requests", wrapper.ListRemediationRequests) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/remediation/requests", wrapper.RequestRemediation) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/remediation/requests/{rid}", wrapper.GetRemediationRequest) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/remediation/requests/{rid}/steps", wrapper.ListRemediationSteps) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/remediation/requests/{rid}:approve", wrapper.ApproveRemediation) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/remediation/requests/{rid}:dry-run", wrapper.DryRunRemediation) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/remediation/requests/{rid}:execute", wrapper.ExecuteRemediation) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/remediation/requests/{rid}:reject", wrapper.RejectRemediation) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/remediation/requests/{rid}:rollback", wrapper.RollbackRemediation) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/reports", wrapper.GetReports) }) @@ -8200,437 +8762,462 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "9eX6y38BAAD//w==", + "7P3rkhs3ligKv8oKfjvC5CeSVbrY2y6FYkIuSW11y5KmSnaffZo+bDATJOFKAtkAsii27RPzax5gYp6w", + "n+QEFoC8EZlMsq721J9uuYjEZWHdsS6/9CKxSgWnXKveyS89SVUquKL4H9+S+Iz+I6NKm/+KBNeU4z9J", + "miYsIpoJfvSzEtz8TUVLuiLmX/9L0nnvpPf/OyqmPrK/qqPXUgr5ml/SRKS099tvvw17MVWRZKmZrHfS", + "+5EkLMaZh5CSBePu30ICi+kqFZryaAPUzNP7bdh7I+SMxTHlt7fFU5IkVEJCogsFeklB0n9kTNIYUipX", + "TCkz7Ldh73uqlyJ+L/TLJBFrGt/eDv8qBV/Ad58+fYQVbsJs573Qb0TGb3EbZ1SJTEYUuNAwx7V/G/bO", + "qbxkEf2Bk0vCEjJL6O3t6FsSXTC+AGX3gBtb49UJjldpfqCyZ750k5o1X0aaXTK9Mf9OpUip1MySyFIo", + "PWUI07mQK6J7J70sY3Fv2ONZ4k6nZUaHPb1Jae+kp7RkfGEAEf5sa5iIokxKGk+JroyPiaYjzVY09JGi", + "l1S6HVOerXonf+sxPhe9YS8R696wt6Ixy1a9YW/JFsvesBdJpllEkt5PodnwGstzkYRKbRaWhCsSIXiH", + "PcY1TRK2oDwyuyJZzMygleBMC5wsOHu2WhGJW936TTNt8aP2y2/Dnqc6PJqBnNtl6fD++yoQiz2I2c80", + "0mYdf8MfyYIGbhk5zDQSmcXQFeNsZQBxnE9ljr6gyJKYpiv8LP9HG87muPVbPheRkuB/c/pZT6NMKiHN", + "NDswqg4TXH1Y3Xzw7PGK8TORUHXmuP82BKT5ufOZzGSvuZaBQ9U2aecN7goxbGsjJLrgYp3QeNFOETup", + "rzLRbHMQBSMVTO2fA8g7E3EYqyNJid6TomMaZ+n0goZnjJkykueKMClmORAg95UdSqpEcnlF6OSTHAgc", + "mSXUAeem+TVLDAs+eKf59xnXLDkcYkoTXRUbhtmhaChRX69YsFdAuVdCx+AhNVlYjhDHzMgfknyscIrt", + "D+ospkm4DHtZGu9JoCGBVJBshVUEBZQFVQdBZeZ5x+Y02kQJLannVUXnQ2pBAoYHwVxI+Pjh/BMk/kOg", + "PE4F41qNwQEfSBTRVCsgHIT/HBHgOZAkcT8DAUmJEhwnNSoTSnmIqSYsGfeGdcGBg1Foks/vKF/oZe/k", + "yZdfBS70Ssj2WxOsVFik7ymkURjdjIQO3nLKPokLa9RUL/blx7egzU9Gsycx0WQMn1BxjSTVwBS8f/3j", + "6zNgPEqymMbbN3KI8KGfUyapuhL77MjfE6L0NLuqJONkFabsVNI5+7wN1/eCjxwMY6bShGzADoU+HS/G", + "INYXU/J49iR6Gj8bhEXMpbi4qoQRuYSo7s7csPkR1kuhaMnOtAaoRYiISGnuuBNvQgDl4CiWrignbah5", + "isNKDKiKZdeBMf4S65xjxbj/78f7QNGopSV4kcgwOwX9VaY0zCgQMEKJI6QHO8HoIOhX2w2rpnsla7ch", + "pkAtzQY+vD99PQRmTFKmwF4IeNcMCJ5sxnCuhaTANHCxfm7+PyLcmLMzM1JLRi9pDGRBGN9mASRlU+35", + "Syvf83zIsL4wQ/JHsMQzhnM8geARHe8EoZ1yWNpPGxTfsRCi4Xd7cPLyidosEzdvcENG4r2+dO6Kun1i", + "4fJLyOLQQjrE7GCemMGNpkUkpKQJ+kiaVEorkZs1pMrCxeFuVg2PhIz3/ii2QK1ecpN6l4tm6bxPXSGe", + "j/dA363elpT2Diy3dmfDXu43KV12Bw0wx77rUW0KZN6t39SVTPKPjIL9+TmkRCE//Tf7hxegBcypjpbI", + "c81MkJoNDw90ZJT3EgaMXr4TC8YbhZLQaU2eVKVJSCs1p1oLGR8giDJF5UEyrAaAfJ7SbnYAoMmPY5R4", + "pQrmv7Xn1ZxMKZciSVaU62mxjS2+LzOjjlAOayEvVEoiCqlIWLTx/nAF3795CbNM4/2bQ8CSKPS52hVo", + "7HVX1GVQ/mmWJMCUymgMfSXmZuxcyIia7Qye41RRwijXgGIbf8OFil3DjM6NcCR8o5eML4AmqiyNZkIk", + "lHBL9HNJ1bIFIGbfu2lIL7+nOdTr91cBen1Nt0LTfX7/5uVrPFjznaZSXDIDQMYX00yy3exo64uW1X+k", + "ks0310hStb2YCRqXpx8LTbcZACymXAfd8w2ikakp4YJvViIry5IyXojObmccWpszdKCS0r6PINta0p21", + "OmEzBJvBRldOPzjUVGsAUpXxdYBgib/ZPbmpmw710THB0yXhi2YTBMUo19MKB2/n2Jyuuw+vHWVrudp0", + "jadBprnNYP/q2epozWIKJNNLc/X2Ycyx2pC/BXc0Xc1JYErDrlHkglFdNpYpIx9dkssyG/Xcd+S4bwxE", + "Q2JkyyDMSB0Pn5KZEkmm6dSocyLTU0UjwWMV8GS4keiUMqMhIilIuiAyTqhSIOZA3OMIGkNuptL6pUcX", + "vz6Lkw5rv+V+ZlgzHov1GF7mYsiZrtb4WhG+8SsDmWsqgWkFCVHaQC+8mf3dh8U3B7ls668qJSTYAZoO", + "N1c5TzsW/4ADG72SZzRNSESt02K9NOa4w2P4VmQGwv0c3+xL7EixmA5OwOwdnh4fj8dff/Xs+BjUEPx+", + "4elX5u9PvvzmyXHtl0l2fPyUvsCvd5LKYTi9Ip/tU6Bbf1g8Dpp9HYKq+ZR41PKEoflu9OqbbvvMqjCN", + "rHeXWrW16fLw0KKnRNOFkBv7qri1XgXNGgVaJ5d9MVFwH4Jz6ljHt5KSi1iseUD0+NeZLRLIuKQkWhpa", + "hkcQGckcZZpd0umcsCSTVCHSRk97w4IJMK6/ehbkNDFdSBKH9PNOy9AXjzuu445ZXaNl3o7750YOTVMp", + "ZqEzcAH4opiwS8qNSJBiDfQzU1p1m17whHG6P3BeHHeZv67J2sVKl1J6pnMgrJ14F4qdLml0cUZVloRc", + "rFKWXETVA+oo9cQ8NCfEGQWfSjrPFI2HMCOcUzldMbUiOlpigJX5CCd9DkbegOCgMrRdOnlr6Xrq4MoS", + "pjdTpYnOAsL3o1B6tNwoTSVVaPOZcdBXZEXhkiSZddKmVDIRswgSIVJYiyyJYS2ZRtesf1DML9JIKV79", + "L3TmBp8OLfT39EL5qcP2gtOyEejTVeDUxwacDr96Hdh4cZKtucsHaIb7TtwSfM4WzbzLCIHA5ZmVwWxb", + "XpIEHwHLLM2Qq4IZTcTaetqXhrGLJIb+V154D8bwis5Jlmh4/OQY+k9W5kYbpd5Xx22Mr/Mu63tcM71s", + "ZYzhHT89Pob+lwftWKx5593aPRJtyJLMxCXtBM2vzeaeHh+yuxUx/8EJj+h0kYhZSHaVzAdEQEuZmkUX", + "Ct08+EcFjmuroJ1QXicMDCoVU5rGhkXFIagwDqVZGq7pKwOKr45XgzG8Fxo2VBsLSowwuJDGz0FLEl3Q", + "OH/GTqkcmfkrc6uEYUTZnsC0kuD6UfPFcfi035jDPj4IKyXRdJqwFQuo69+Tz2YbzrCF8/PvSqJEQZwZ", + "5gjzhFINak1pqqD/eDx+cnxc2ciTyjYeh3ZR0juDGDGy+Pbp9OPICi5wX5TsQlz7aXXppztXLjGvaU5e", + "23s4LW7DM3Hl3Yz5BP/6z/8q80Kzn8fV/TzesZ+gRoFQqXG8YZVNl7jLNolVwdt05AoqBPlBN4HS7G+K", + "coHT5ksNiChk94jp6pCv634a++fSnLsOdp4rMtUDYagCYqZTJWrkY7iJpJGhHRw1UppIjajrNCwMeUH2", + "M2dSaeSlZc1zr+fy8pW5QKvtPTGj36mcjs3xphYi48CVhzi4PXAeC9tBC7dfOLI54EunhO71JcZTTTE8", + "Gd8+9/h421menze8o/AJG/cQvKkgDkqK3l6SnCaCNzs6mZo6XA7xcHnhwgjMHECs80UZ9XsF7jPoC55s", + "jO7NYvumoyKR0hd21CCIBt6/W13OSSQFWsAX1pL1OQBkRa1qBX3cyuALu5RYMa3RWNrzeQ33WI4utNvt", + "2TDUcGCk+SQcG+JzOErHx2mGV/TF2V3uuN72WBqS6eXUJXKUj6uWLriw5HKeCb0MHr3mIinB+vHxk2fD", + "4CNJCauaEWDPWyu71wOGGbs0NNMU5lz6HR3r6VISFX6DuCJ27B0ze12PvXbfeYBW6W2kjAbt+PSOKd0i", + "h/Nx3aMEirmLV84dL1XlZdq32/JcfR2Yf0jAo/8m7I3fRVyHPqgxVRLc2xTXlSLvnBbc/UxnTJefOMvi", + "2Y2IxGrl4qgaZ5kzvqAylWzHuMZgqYOeY/Z7wuxGtZUrLF93O31svarcP/FQFabnVGtjGhr0AMGBeH2j", + "4AlAtFgZ+yXZQExXQrtXoVTSSyYyVdNQWlWQK0iguhIwojySGxsFH4Msv1cpLTDH031tFQUu+IiuUr15", + "jnqMUXsuKE1tmIqzm21kZG+4U9ztv5kLurnmfdTE6mHwsd9fx86uIlW3KSpTWqzOREJ3KFzNxNAFvyzT", + "TInWVBqo/T9/I6N//mT+53j0zfSnXx4Pv3r62/8K3kPXEJEV42/tj493xovUXrV2x40UYGoWzAc9uCHT", + "mGUs0VPGwyLsuoJktg5dXnk3CF4xFYlLKhud5DHVNNJTwadouBsTWpNIt/pMMQPniKTs6PLxkXPwZlqM", + "KP9HRjOqgKD/bxz7xeFnMcv9k5yu8efC66QzyQ2XfXL8eAy4zpwkig7zofi0sgGi7X+NhZr6uVEawvsf", + "3r0r+SCMshdnCZWQMpfbvoIsRR80B3N8ooWEKMFfz+hIZhzy3QZZtPdzNnv28EwRiTEF6V//8d8OCl+o", + "YmaY0UisqIIYxQnmba/7Axg1nYt+jiiNAxEcYxcs8NXxs6+PjwsHqg0q6D95tqy46+ywDk/6e3rPq8Cu", + "edFzdODCIIDltOYLc9iRSwZYmP+Z0SW5pBhzy+bQgJLAlMWLcPzjDvfvNkIqv0Esd1A6hjkB+jy/rPim", + "n3yZB3foZcZjaih4tKQytpqBdR/rpcFSppXFya0bVWyVJZpwKjKVbMp39OXxfj7VCkbWnJ5NVN3ZHVrj", + "G1f1hdbZ0B6O0K1PD/KC5rOcrylNW0IJHUqEHOcZ1yDmQUxKi7eejSVWfEYYw/9NpTCIS5xPSmlK4g2+", + "FFPo29g1ZBwkkfhLgS34km2RCtUOJOiWfP0aXPKThMDxOlqKRqVhRZVy8fhb2vM+DgA/T/MGmq3lmOkp", + "vaS8KQn7kDwSGi0F7RA/48ZtzRk8h4XyJ6r0n8WsjUx2bu9nMet22Np23XfdtlupKxIOxAhtPr7t5J3c", + "HvNGoA2Vx1AvLGwy7NmAu96wRz8b/bQhg3yZrQifllA6EBCh5aYpIGKL2cRG/HhLrvi0vtA28OtIhqAO", + "3tElSTKiKabqNhKpioSklSC7xxX5sZtB2BmCO/gc0Vwjvv5syJbaCuGxYQ/9d0Jp88sQUpFmCdG2+E3C", + "8LHKVX+CPvJOjABifJHQkRTrUva4xJAkFUyE7Ro8nieGB37C29s7Qsd/1dFpJ+klo+spF7oJw83vVy4a", + "4Sa5iaIR+btkEQ7lYIBJlakUl66mgsFR909MU+75nO5QbYWQNeWxr9hRfoX5Rmp3ULvIVpIJ53ZS/3N3", + "R3VBhLuMxNLkrVvrmOLcUH8BB23cc++KEq5sUDt1dldxH4fiVzMZNaJOPdisfqM74GEwehscno4aAOEI", + "QQKO6+SveWO0uFOxShNmFO9zo/0FnuFV/vfa0pwC5Vpu0FCpzQOMQ0LimEoQMqbyOfyTSjHCF1qrZ6q8", + "hkKvVNMpUP4rf1lueIWuyOMiHpTjg7DdFL4hE6kZ/rISSiebyo/lfzdHNtZFlaspUtpl6GZbqcQB96fd", + "1/NJUltcruYpIZuddxOTjQ16Upykaim0GoJIYqq0jYtovgByuZiiRJ6mUfkOeLaa2SvI42RsTFrwmmJ3", + "SxUSDImMOWGJ+WdwlsYFaiB1k1e37j8v1tja+t53h6BvvDmjCLxx8ahbcHWbmCJr2CfuoruWUttsIWAC", + "azce4p2LDA+wo1pkeYe9V2J8O4z3hNh19H7zt0QEhyOdO8SWI8zOMCTJJs+pq6aP44yfSlUAd/pmG4ve", + "2M1lCd2JlVW22+Vm9xeFgbUaN30eEf7vxqDd3nLhF+m0Tc4de9kXPdw6xRwtmxUh2KZEGVV/OpdFCYua", + "t9aOAAMjBUfQd5/AI3DAGgCJpFAK60VZ3ywcj8ePxxX1RmQWb7dYtRaaJFNqDTmv9bW4kyyHsE4hKdYK", + "1ksq81clFwcPTNnKBELiNscHZHZswSa010aAfxLpGwuf77yEuDLFlZn3FSmu2J6hvevZXpmKr7q9Em/Z", + "drEgE5teMF55+rbeW0Upz6Pw7Mi4VHwt/9NPVzS2b7RiSseagR1sQ4PEjq8ZKFywNLV2YM2lsq8VmBt/", + "5cvYXcbkT1JkachploRKjbyiii34yBW6MmPw7dvW5mJ8Lga9a4r86XibdZRTzJbvU9PIpSoG8ar0jhB+", + "AMVsrOmcrFgSiAn4cA72JyAcHw9hYcAI+BVVILj33MyFhBXhGUnskLCvZkUN71VLlpbPYr+zsSsieIzm", + "SKNsFk5IeyMpHRmogspmoygxHHXOqIT+imxgRgtn/Q0VY3ThOA47/T6HDuEqoKheUwWNduZCI1rb8IIW", + "5L4+dGrDljym1SADYktxyOfgq57V8eW28aRTlTV3baV9NAK/PfjREkNnaYYz/pXp5ZlIkiwNlUgqla/e", + "OdO5G7tlZbu/D/3+Gk/3fYGZzTVZeAeffIObxy6CYN6r2nlQgjZff0Nq6HeYgJWXC7cpYH2bBjOEWKz5", + "EJy5M4TxeDzYw6zMN5Qvv+P8jfC9so3buLDDsqCbI8o9Ld7XUXuup8SIRj/Iq+IqIpzT2JO+80T60Czq", + "f2/2NZbsk06+FGcR7+Efyel6P7J0WBogySLte4di730uReq2NaRrBy022Hhz5wUX2P/qUNFF73DpAqs3", + "ZQtZ7XNZBadruIppWBkp5zHm4qdaU7ns4WS66aeM4xYqb8dNF+E26+erLx3acXmBxmuxobP7SOJrElmN", + "OyoJE4MdSfJh3jv5Wwd07/02DFTld/Ps/NrLr+36++bP27v96bdh7ztKEr1sCQKcTV0eWeWOyxVUtqyQ", + "Jc65KVdJCCkOl1SqcGxhwD+N1kdlM8UEoXswUqbwW/vSIgEkKf1SJdi/UK4IRESTRCzAj3sOGfdI+0+f", + "SoRxrm5gqWVMloTqo1Y8zF3y46xjouNo9Fkc4lzKAVGsWPZT23l3Q/qNJCu6FvKi0bMXqtm7VrAixlYF", + "LWyQ0NzP4zKlq/4e7+TZPn/+XZNFXQLnVXdhpho3+yJz52WtCjX6whr9W007GHerhlJ5KKku7E5+VGwO", + "iAICKZUR5ZoszA4yHtvVjd4Q04itSPIcji2el75kCo7H8FGsqVR5bn1CuVFGhKS+186PjK5HREG0ZKmq", + "HGGeCKK3PYQ1rKxcZwWuYTQtjr8HqraUHczX7663NNFDSIe5pJIkycEzNgELhaubezcc3tG2wotV5eAA", + "COS8N1S/N0sOnvcsS2jQVosIx6hO+ll3mfE8IvzUDe9u6m2DsNHuK+9nWDIDK7qPhUS3y2rUQ/MgtQ6M", + "4iaFUAcmhPJkX/5jP0LW0+21wXtAb1h2BjlRzfnqp9+PRyGS76G0nHZRV5x1PQ4Hi3ItRTJlcfBlBn8E", + "Fqs8ZyGP/ylE1XPnb8o41nj8t/yHF+buFuyS4trdy3zXMlFqhhVWVVDUGA0RNXK1rI6Vxvpt7VLZwAPR", + "UCxW4h8Cvrr69KdoSaMLEJlOMz1u7G+Bo27oKaCWime35QdsndNwIPB12dEjENx1k8OmpvU4AJutWinP", + "lI3TxrfCIepmQ3DoP7RdHAfBBfPePGEcxp+fw5wkiYIZiS4MW3AQOkDvbnwF9u15qslEJX241MqneAgp", + "0cn2he8m7HOXUxEyW3NHwi2HFrm3n5rLoCGdp1xCaZ6QRd5d0R3MaLPt6UIrxrNgUNepSxk0KqVygVz2", + "m1wSZFxRasO2QgUHP+spil0S1L+9x8Un3LiS5ZWtw4bqgwP28oSZKeVmYENXEKRLm6QBbiCCMlx8N58z", + "JZlqr9ZezT2KmUrd25H3MWHuhK053I+ZsksLCTbtpXy3ocIgWwpO/bSB3Q638TqACgEM7EBK914dun9a", + "yG6oXkeoH8mD/brG+l1PlF7penYop+H4lO4BfuXgvv3cJodF9eEdHZrY3JDl73phTZtysLezhfglk4L7", + "og7lvgCh+dGBe2iZifIT07452umUxLF0kYO1Xe6qHyCkrqSQfPXll0+/3FnizrUrzJF7F2jqim5bKvzu", + "Zyf38lU6dxMKvcLUo7acrIJXH2IP57awqz7S4eNyzZmkFPHZzXtuZsjjRH/7adgo87mAvNKwLThohD9q", + "9zLjtpmW0oVWO95C0hDka+LNnb4J+m8IS2h8ddOupOa3mnRGL22LYL+XNt/v04a6ZyZN2Ihpt12qCLMb", + "h9s6Su/t3iuRRgAnbEBm4ztCHjj68t07H65qneoecZ2dKpQe5Qg6mrNEUzmEVNIRZokPDokfre5tl0Pv", + "HeukweZv1O1vJ4HHiRfmCywr4OfIcRv6EVF0xLiiWLTwkg46Pi48KNQtbr2tu2q7+7eargJxBDJaMk0j", + "7SLjd2pHllJTyYTnap3L0A4bhPw+8jYo8luEb93gDj6A9Z2vzYZBDAIC+F6Ue6trzbu05JtTiw+uRFfR", + "j8NOzJIX5TBXyP1W5apFP1bVZP6S86Veq+RweAhVCuXsMroUJbFzeJoQbbY1tR3E5ozKbt85a6eDRbPb", + "hLn5QnxhW8cdo0p4bTy4WW/Jo9g66y05T99l2Ten+VXwPlRMZqtUfPjKkG6rfUN2U2m9yvWBxG6myVtZ", + "WMjtDmKzjKaUwXGlPRSRpdvu827dW+oBaC09Rswq3W9ma7grMd04XrJLltAF3WeN4Dc7Fmpo6XK1BixK", + "LffYd310647DaZM7O6MYCusSW93otMdaly8AXdquE8ZzW3gLXmC9jBXd7S73szfuMEfg75jSzf2wrO6H", + "ZNG9xz+yhVLJl53ktCcjmaP9Nk3IxoqePDHJac9q2SvhqIHBtdZEYV1tglvnEuKiBX6Vyvu2Nuq1b2u3", + "EpBzjq57DTVE2s3uDaV3W6E9O62E/4H77ExdbQXP9N5hTw3EG9CTriGJ3m+w6awtVcRqhmaVzWHC3eev", + "v5p+9WwIxIxFKrpha/TBpLtxk+5OjZ1asidTWgp4+wrmUqzgiOroSKiRpAklirqkT7mkyRCyWcZ1NgQp", + "oovNECLKtVBDIMmKJIxnn4cQ0xkjfAgipVxlio4SStIhqISqwXOw8e8+qbJvJoVf3TfwK5gP4FcgSco4", + "/kNGS/gVFmYZAb+C0EsqB/Dh/bv/Y+3Ot69gbQxNW/AYEzhSSUd5HcUxnKc0csWtMVhiVNREvHw8fjo+", + "hpenoydPxh1heA0mYIDA/cgTmnzTZSP/84zEcmTo9jNrJm11v8YGxH8lSTKKEhFdgB/sg6cSoqnSNhGI", + "ahqjw6I/Z5yppS2GOgJsJIT/MaikCZW9Y9jRka2o0mSVKiAzRbked0odqrt3qnsvb6Vpz/ZJJOONuxsf", + "HEBjCxiWEb/eXtL8Du73NqhWHnK2dheI3CE82DuGxdcEh4MazFQuq9jlFqAaMRl50Vs+F6EMYpUleMsE", + "ch5mzjMG30nK+22nlqdNGZ8L0Gb7Ex6JJFvx0VzIkf1nG/sbT/hWi2aSpkSuRCVWarfuub+rXCQJJg7t", + "xXRipi6mc0npdDHrpt7iF/YxaK9PMkXjzl/MmaRrkiRTReUlC8Xp+REx/ArZfA2/Ap/jjSn4FVia/xOp", + "owtJFkvm/oHd3/wj7ia1Dk063jnxBZWcJtN9xzs1ZJ9P9hHSK7qakkvCcNR01fHSzVcWsbp+cQX9K6h6", + "jcfjsD51ZLWpI6NLHVlN6shQ6JHVoo6cDoUdyLZ0qGvXl1jc1bnO4mnCLmjX4Z3RSKhpKqnWm70+2QeF", + "iuHTeWZTiG7seUBR1LMbmz2/xn7+jC/gV/joui5cGlX6lQ8tdXwG2By40EZdVrZk8e611yTdC+sbreWK", + "AGgSlDva8Nx1bN2OqLUr2Z9/+HC5rQt/yzVNEragPKJNTUD2bG5Rjbwm8aWxtRVgRDorLWf06tkGyFyb", + "Yb4F+zxL4Czjp1h/uf/0uLHp8eNlaxvgpzffwcJvc0cr6D1aDxczVntOMK60bQLt+g6XmmEf79WDeI8u", + "ER17QWxj0FXbQQRwco+OEKGvD2oKUZ7o9aVjRnvX7T+wwr1t0bGnpm5bIvh6/LXAsEQoGoMmnwUXq80J", + "oAPEKhzjlEQXZEHHzifRu8eV4srhi/5ZwNhivWEvwWybFY1Zhj3/2GJZfh/Yt/RbCZqV0MHytqsX1QmL", + "1Ef37nSV6oPbuBkQDchxo0wq0UXH6VyusLz2uW4qO3KAobkPivmkil2E1VSdza5Vlkb4ZjL282KLplXe", + "RRqPI+T43C+7q4FDgUaq+GSn+vWORZQrC9UWLoqNy6hs7GRyDV0Y5pRo/1Ld3afJ+HQhSUSnKZVMdC6W", + "4rpDGuWN4Judr9k/7HExTSxQMFcK2+QEXxg1q762ziXFynIp5WusHpcmmLRIjcRLJVM0OE2GBVBTSS9r", + "zXmbXuRw3VJSZA64lvv9kUo23zRq2O7A05/Xerejtzy4w5KND323hjMN0xQ4pKb2loPIo7sabJd44Knt", + "4BHcyJpgX7krbbfONP3e6+uHbua9MPZnhNrD6ZJwTgO66EuIacLQDRDZMWM4f3169vrTORBJ4f3rH1+f", + "uSZ5NEauZXTW8+8/fcxbeA7z1moqIdHF0ZrOlkJcwA9n7+ARYK3RIU62lkzTkeDJZgxvhAS6Iizx61oX", + "6PsP70e2jaXP2mSqWF4JHERjpgHZbEQ4PgzNWZKcgFrpdGrjz0lixhK5oHq6ZFwPhvZXY0UN0R8zBC2G", + "4M2b8ZbP9JDH0pJ7dRu1zKKhHq88RjMGzULoV2Ey6GRVdhNpzRW5PGACu8N7FlLvsbGq9Tm1of/NcvSX", + "HY/IPexEBMaQdQ347RZ8sVb/+vACbD8zYob3QtmABTYEMpYF93hnEcggL/5LSIvt5j+CdVV10OEfsZQZ", + "k8tdLA1e7eFczEcL5YVODeX1hj1He0YQmdXCMuiKL4MBFPE/u0M+t+WKfItfptxjjSfkwd6vMwVK5yVo", + "i0zo8s1WkG6/mrMBhllUoK0pekLWmJ3K0jTZQCYT6C+1TtUQ0myWsMgiTpnhuaE5tzrKCfDI8IgjLaBv", + "OKoH6lEBSFcXgiZkAyTTS8qN7aGpGozhZZK4DsAKua2rpOE6DdMYuXT1Hra5XiUuz7VBa3ofCjM0398N", + "3kixqvG1cI2Ka+8/jShpwFOgoF8fzmtQCT9R+qtpmNtegGUU+0/cwmrtxCWGaygFLekvv/7f496JIZpr", + "ZLTbPHIvXjbYL90QNYGW1kYzSiSVVmHAdDJPW+YqOwL4SnxRBhSkT8hbUJcJ0TXus8IJOm50B0NF7K1x", + "1W5IVrcfSjyzI9MLNxHzcquzDyGkf+4K3c8X6bjVoi5pFYb27wqQfTqedlQQyRjOkQdjc32jt1Y0zr5h", + "4lv3ig1RCw1z7tn5oGjfzrRtzo5N+5mGhGJf4VJXd7dQxl3ThFYG3Mxxb4KFtjPBdk520wzpAB7TRN1X", + "iHmq6R0hFP2gIpK8ElHmH5e6+45ecvhwfvryHTweH4+/gpeGz6oVeu1tl0iI3bzQ//P5h/eDIRBMyoqz", + "yHb+xWqqXyign829GCz/kJJ/ZBS0gA8p5X81CjOacObeqTHZpMgWS7ikckY0W41DavPHvMd7U5B8KZ1+", + "W5U3aC5FpsIIfWD/e++UWBDdpaWufZss8rSrVaiKLf7Uenx1RhdM6bZw5gMKOfrSjY1BzE2t/Nsmrd9Z", + "qCyk2Cdv/EwktGGqcI1bW3GxvHe/ZBDIImERo+qMJoLEzfAVmcYO9lfhKfX6/X7Kxn1tXtGIKezY2FjB", + "+bBHmN3dgd3uwlV2bVxaQzHtUIBf966XYSD1qotuLdHa+PKjFLZ76js2D6i/r5VmK+yim1JZVA4oFXIf", + "2aq3MU00gX6pomUqGNdqkFtHWUJhnrBUGcaHBYThW6r0iM7nQurnQGDOaGLN0lLeNNFFqY0vFMREEzMk", + "43kYUaXGQSjOLmKqalL7qpkNxm5RE4k7neuAT5Vmi4M+DcnbM7qiMSOtDVv/YB2UV9QoY0ytmuqTywIm", + "sCQ8xhfz2O+sXBsGiPaFWzBmOLit1BPCNHGU0MrKK2SDdDkTQk8L8vwlmDb00Pq5U7GaiPCpzPihAT2B", + "9mCUx4wvprZttG3rE+wgHcsNruyDnPFZClMzMY3P/tt+JJLEHN9asjb9L/xGVfR4qxRY65jtFehFtm8j", + "6m320dTDaR8msscNNrgW8AJsIc1RqdZQxmG9FIrCnOGtWQezp3dUYa4WVL4N3G5QC5vgDvx7qG3b3HyX", + "/pavsXOjt9W+urTouaahrj5pmrArMhlHjWFueogg2Wb7S6Jo6b0wrzcrViumQ5Tu6+D8dCUmF6L5gtL9", + "uX/aDfgwTipN04MQEu9yd9tsmjahonc+bIWA6b1t70/oVOcxlTQGY1xDKpTOjLbpbG7rvyccHI++pOAK", + "+pxMeLUv0RCKXrdD8D1PsVbVEMpNmdVwwvOKRkypzAzQIp1WBlk9c+v8hzxPGpV2StRUzLt/40eFUjEp", + "p7KUHHpdPSBzEIdrHkQipdOEzGjYn5OXh+vyoORquPluhqWpK9CqnTWHyjBHt8qFNONre00UiWP2oSak", + "gd0s3U4b3FZu2O8KxO7qm2FqOstYoqesgZs2OTR2+PZC91d15FS9DeV9BE+eJbRBzO5VUs/PEy5NUyqN", + "VqtzgX0WbA9oZst2JWwmbduKXcU4cINtlW8rmwqWEuYGgxNsWOCsnKxk4LqtwEyKtaIyEB3R6vTbgTlF", + "xxlJ563OnAP7F1R62sBokh0fP6UQlcps9hVZUVBLklIgypeerNuSBUAbkL1kGnbBk5L4ay+VmRcU/BWW", + "bLGEX8GGncKvkJRrvpfgsXeCcBOjDNgRNdR1TpYvFBB8yU6JXgJTQCAiqc4k+kqIFisWQWku6JsvJz37", + "y6QHii04STqUP2/pIVCuuVl4eBEaW4hWP1n1/pro6Kx6x9tOjBHjMTV2H+W64ixwGoJN/zJnv3D+BBLD", + "SsQ0gf6cRFph0tZzkExdQEIvKfZfMWRAtJBgnWxDEGtu/fy5L3+wTZe5r6qhsg56K1zTY+tu63PBR7YF", + "7aCye/qZNaUc5JpuIFMpZkozHlUhUXzg+2kyY/Uazcq2fXfVCOwL3FRRPQSXd+lzVgd7vTU7L8mMLskl", + "C3XC9ldhhjlEPIGJOaQepUSS1aQHfaXJwsDcjLEvdkNwBr77dABCwqTHBafmAy60oQJn0oPDYTVy6xCu", + "1lQOwq4UTEZXPt00AFn/i4sBKaAr0W2Orfn9NHsAq0ZrBQZV7jm0w20wh2jo/PzDa3uFYXFrbHMW79O2", + "tJjxo/t256mKRdq3mE8Y7CaBmS8kGQIvwsWsDMmtB9eig3DBNyuRKUjEgnFIySIQY3i1yL3G9twNR2w+", + "2/n5B/AQghXVxOi+YzBHjhIM/XCHZcpFhDIeJVkcesC2HzS5aw4yW2wg0lSKEFszGiwsJOHaNrXKFJXK", + "nsaogjSGS0asY8cfce/Qza55KcaGC4D3w9tXp2B/hB/O3u0XnGlskgAzOE9JREcxxWwqGsOHl5leghvd", + "EhhTS66TQotIJNAXLI5uvm28ezdygBqWkCU/ae2+y3F+O0ysEorv6N9QwdEdMRJurCWAxqLmeUTO81qI", + "XW9nEEYH9DZqeRmjLY733ZdgXWqD/dG6G766wKfBNcbudcTq51huiOFTHUnWZKOAxDHdXcDLV6UJ4Vn1", + "Qncg0jWKrOuTVX6mrUzq2jWumIbKaaEvJCjKYxszPTD88oLSdDtEaQdfvxrNeG+wD902W7BaDeqc2KHb", + "ftqBXA5F+etH5UNRMnjLEeGnSxpdvDY3HWwTZqz4SKxWhMdfKJDUhgIxY3tR9xH0fYqZVXcrM4ash0hn", + "JOxWcys1JISxIi00UNL+c1rvbV12muulaHja0TGVsuknkekG49W2Y4w7PIC5xcsnaLyMhuT1vO3bdMW4", + "CkXPjtC54DOhzZVgI65B7mzJp4AZ4TH0bZzDN8cYsE1m4pIOxnCakFVKYzOPgL99OYQnX399/FMvXObP", + "+ZQP2hEmmnuXVO6HKO8sw4yVJ8eAHvFNMci9ou2328ZKsN8TpakEtWY6WubAIjFJrfvdZ6+PARPqbY1Y", + "dF5V0+i3e7ON4QMfxdTgM3p+bLx8xsl87l9nAzbvPpn97Yn9gW5x/VK7OKx9OwhvotZ38HCkq89UueH/", + "fWwkw9ff7HeTlXaJh++sMk1lW09wW8/23Jbr2nj4htwEla18iVv5as+t7CrVYGkvRw+MhYKvjlUJlQwW", + "1dd8PITHxw1LusiUw06PquwoSohSbM5obDe4x5Eb6jLXtlVnWUFUql1kEy0Me1t/OKTqRMHyr1ptoiQ8", + "9qgyUf7qoOoSZoJXeVhi/Z1J+T10U14jwq0nFB+vGxqCd5mlrYG3dTG1HiisjdfqENQsXKIUEAX/Zge8", + "MGQ7p0aiIK+hnzX6Zp67wo7WQUo/L0mmrCToWDbCiJG9AFrq39b+BI4zN0HEXEu7iug89fMsSSCWLElG", + "sVhzSMkmEaTI6PXZYl5zzPhaktQQeZpkqnAKbdsFRqfc7+hVxTb4mOMRt3oeDJgdSUpifGK4pDJmEeZt", + "uM7v4drqgRbptjpWqVqa67zMFORNh27j2WrrUru2Vtv+8YKl08aA3ubW06khkF8xMAF+9T2l4dcmMDT2", + "Q8tjxkovMu4ehx5JtmDYhtZneahMI1LH7q3JI4KRWQRVujG8F8CwnPtWL/Gu3QCjWjdA6Febyk16lcbv", + "k96gQ0Xs6hKC05Hd43YvdRBzWC+JLoKaLRRrTQndl1m4rM7dP7WGXlOXRE1pI9fSpSbLTNmjL4kKtYY3", + "14BMDVWRcHmsg3oVtj/Awq8w6U16RZ/sIO+5JYps7IiI3NKCD0cU75Ae2/rBLomDK3ZBDDCCrYfYCgY0", + "cwH+7xnNaKBH8j/w7/tVdmqqO+xDSNWYxfDiBeDc8LOYgf3v0qOxGhd1gXfXENoKDra73l0pqlgkB2Zx", + "4CZo+Xb3H2VDaOYsiy6oDiDck2fg+hwMQXCKVsdSZNIiDBfr5xBnhqhd70drptjIWdcI3i4dYwFl13uT", + "cWWsWUysMLO1dKTOaFvvVPPxVMznKuRL/E5kUuUbhf4xvPBOFWNXm28HvQ4lLIslhqX97O4qbUdzsQ7H", + "BrSByfA0khhtZuMz8vre9kOaTMRicED3/XMhOFU6uKZ7fbdv/rklZG+0Uly72qpfWc/swcXGHfb+LGaq", + "hf7sSq5YYbIBRy5BSzrjnPHFvjO6z3YjhL/U6tZr6w5zkmqkyaLp47YSU9klKuJWd/miULfRBeiyryz9", + "qW0NplRI/vAA6H3i8Ttklh3AnzGqbZor7KE7NwPmzjJo+t1QUtvvpcamocb1hHcuEZc3Dzgc6k0qgAUf", + "/OoRFn4tFcO3qkFY39OSLRZUTpXIZEi3EnzqHJ+/5gS++zmtkEelunNeMtWWLN99KEOwuKTKjdavp4oP", + "TfT1I5HMAPjDJZWSxTQgXET5pwNLEPllsJ6A0ZT8pHBJkoyO4eMPn4oyAEb8oLm9IunOQn7F9nadsaUJ", + "9KUfEmrYLNNMjTIjXfJhoIRBXZhtwD9LhSWz9YiraR4S29ANOsJVXDirpHMqKY98dQW/bPixAr1ZmaTT", + "UI7gB7kgnP0Tw5xGKqURm7MIEM5LkcRUgn8DNwvlIXNqKbIk9k/GTh8aBvPPXY2bcHgYBhCPGM9XYRwo", + "ggT7QYtMA+E5MuwV1+E+imlDlLRTsVU4EKkMYlTwWayG7lb3ipND/A1k4+Ih2aVDcOjnCG+dJVQPgfr3", + "FgecQddXeQ90v3oFGMMazpUgUUOW3bpZbfmCSkKf/qB2ho3Y0jHtT8RPd5TZaPn06x1lYvZ6mK6dPZ/H", + "F8Ap7aoJGi2e70Nq462qgnvvNBHbQNVtukMb020FQwSZGEbYvH2lgCjFFtxGkRm8NjAbw8c8OXm2cbHx", + "SgPlMaadQ/9Prz/BEQbkDJ7bRlal7OUV2WBFGGB6v1pJt9C9aQslGvFAJPQlwqaRMgxou4SG1P0Iojkp", + "0qys2vNlEOqdPc8VlN7FK+zUoX39aFWZ5l0ZoRHnTUOruPb2/MPo66+OH6NkiYu+UsHOe5gYGCjZOZsZ", + "iYYouWDYJck+bW0naAWqef1JgBYiiZaE8by5k0HrGeNEbrBlCIo9lHDBdC0jGgMiYzWjcZynk1C+YJzC", + "SqAJ6Rfq23MzPhdBZ2leTjcUNOQr2Ci6uqQS+kk8T8hCjRi3SdW7BVAxvT8GAimH9bB8eduX/xuWIA41", + "eDrHQhTHsCbJBeOLkbqgCdWYAiDnJKLumUVSmnMOZT0j9DOVEbOSdMLnIuOxSx7QJLqAfqkE/BBYTFep", + "0JRHmyGQLGZGDBsFGairujdw6YLWI1gCWt9tcWBrx1rDrXc8fjw+HpEkXZLxY38BJGW9k97T8fH4KYoJ", + "vUS8PiIpO7p8fITVlJ3TdBFyyZxh6KPVg1MqR9YwgLNvX56ObE0oGkPGnYtb0ohyDVgNXY0n/JQkCZVf", + "YIsCn84FMY2s8sHM/eN8yjqZ2SzT9DksUXuwXpsJdxlvsBRrWBG+sW4A6/l0s5vdYCVGrGiXl5b94e2E", + "21wgDHKZ9N7DJVMYVHUE37tlJj3XT4ekbOTBYQFvVVAm+NvYEBvVLz208P2arKhGnvW3X3rMGXroNLV8", + "u5fbUJZpVYpY+7qmhTcSS1YXJcaN7mRwotKrNehsbFi88NduL394DfzwYiVDMl9rZ5PWhn0zXoNZtyjl", + "8GwZ16geXc9s7lm6PF3HL33AQvFhbqx8ebxXS46fijbCSMhPjo/rmdJpmrjCekc/u+eKYt02oerRG5sO", + "IIOsCSv3Oz6yGwbzzC4emjPf5NG3JC7VK3h2/PTa9vvacEtfojS44Txxw7IKlCHKu/F6P3AbCJOfa05p", + "DP0f3r/98B4NQVt/WMGjyrMBPIIypcIjy73hERSUOsClci4brxg/coXOTmy1b9Q1XEG+KqP5KJR+ab6o", + "lGMvKnZ8K+LNtcEwWGX+t6qs1TKjv90g3oXLzgfuE0e4NVyGJ/RdLyBs7blJaQyucf6gdtuv5GYkMw5Y", + "dJ1oCgT+/NdP4G4l9wFg+5wksVUfA7eYuvJmJzZRq8M1Vgui9W4QkA2l1wKQ/EjlyEDLpZtBXjnttknU", + "agiQkOhCuexID9nq9dkzgbPq7EiYs4Sq0lOodynEEDOJXTgM3XweeVweFXpI76S3vVx+1Uj3O5Uixx5c", + "fK37L2wViTriWjKtKXe25oS7jn04biRFpqk0ipFiSiMjISvKY1sU8vKxUeYGYzhFmTPhKVkw7nricii1", + "lYFXr89Px6gCndgtnEhKYqvUTDhqNbixJp3GHrWbRoMdXIIKje/KQaILLtYJjRfovFIsMUdzWC8SWzkp", + "ZsrcQsPr6c3rGLepGz0oNHeo0CBuN6ozll5/L8pMhVMWhF4yrWoc8x1TOucvsWdPfcdJaDwEa8AZfjUI", + "sL+jX1j8WyfDEMfjw2ff5pLia4HZFycJRuNRhRzR8wA4mvCcCbgC9yxJALEM99PE0aAbQ/t28/ZVA08z", + "NnCByLaEUkXV2YfD3Dj2NiLuvcQ/s6Vnt6jfI95xoQF9LTX8P8cakw45ZxtgcROSn5SkVlmhq65mZVwe", + "FleWdFVsxdY42+i6hayoIZrfXpaWvy2kvX5TAo/yzlfzLNkSv90Fkdh8Ssf27gOxIFbcK2ox639ze+u/", + "tR3ZrC2NL/cY4+v7mVrlskrCJcLAQieWAt2VNtCykyvNdPySbzBmqhBAuHRO2Pnfr0jVr9xGHij6gaIf", + "KNp7YSxRIDXj7vteT2xUQU+c1rhLMv9alsi/esszJ2uve16Rqs/cZh6o+oGqH6g6d84hUeRU3UjKjir3", + "IuWcgj1Jj6FoMyXizRidIuAiyqiacJdvkn8BNCGpouo54J3yBawo4QoYj+mcceQAH4nSgDNNuHS27bPj", + "407cImCI5vzi3J34gV/cGL/43fltHljM/izG0VGhOFiqJ0XcDRbEKUg62dQ0iixm+siGJpS8Wtv+IzPO", + "dl/v5hXP3+/39qKW4kHqru2OM5BIC+k7uOz99YNv+g590wWaNTmozd9BzN3zssPcQzjetlu4PCX0LaxH", + "Jc8wp2uqNMyZVLpORno5si9n7VSkl7bjUu9GgZivEuK8jp243RaMvx1yb4Sc2fjlKuB+ZHSNWsVayAuV", + "EsOMirathoel/sBNL472BfDEfDZ12RSSEuS/aRZ6Q87qgLwB8Z4vUC1BdstBAO1XaX8BF0l7NbF/2O2f", + "2YyQ60cAVAa2SOwIS43uiC7I9PIdDrs5zMD57xAl3PrNkQw4ADAIhMY0fm5bzipb8tBhyuPb12wiSWOD", + "GCTByJTv37yEHHCIWDTKbLb6336qxCRt9Ym2NWfXTC9BeLPn04dPH4Mo4wqp7cQZM27r5p6FOtoj5oKk", + "l+KCbsdkmL/msZi21l/xFFnZnI2gbhMX39ObFhXf0zZMeosXpje3jjPvhQ1K8sAzCGP7J2/B29ilFXiz", + "fNPbAD+qdWdoB/7Han/Hm72HSifO1jAlNwwzNWrgKBKbyuHFeSW+3RCakxPKpUiS3TTz/ZuXr+3Qm4aN", + "X6gVLr5+rDngD2dv8wq1RWm9QjAJCSRN67DDNSqAyhQ19hMyF8OwwgDrFL1oz3GjgYuVNfYSUAE2Z9gz", + "HozdgcgwixuQMyc6sPJ7mpDNFr/FImJyha2LuO0vhZLFFYSdbewhsBU2gbzNDQSlhRcwJzb7aveNfnQf", + "nNrxN6iNVha66t362QLK4y2xd7qGXJr7poHmssp2yS3iW24W+T2tpXD5NSVUQ9hv8YcvVP5ZAKMKLnwi", + "XX/lnTbidkvmG42QbekAHQBVvqUqcN5kSWKTTvwxoV/0Sh6WxdGwyDvGlMVtY/pI0rmkarmbAM/cwJuj", + "PLfCfdb3DTWhig8pYfKu1HwHKLcTx7mHQD+nBlRDy8MxT78fERWRmDoVGlyFBRoPWu2AM4ExCbY7SGmt", + "57BiXAMBTtdAMPL9kR9gANKIXqNIiAvW8vJyRklsw/m+0zr9wJMN/D3PsZu6Wf4OdpohSGHD+jC5lsdU", + "JhvbSaW02SFuVrndOu12iHVQzqkeneJUCmZC2wqOds44n8SuZUsng/1TaUtuvr+P4QdVZPS6Llfw8uNb", + "3++C2F2iszkl0tYKHD07fmzUJrmBpRAXoHy3CbS0CCRGr/QbWTMeizXEgn+hYWFErcjwcVkLsJY6CA70", + "ksoNMO6zyNA1LTLd+D5UUJwFxd3aP/463C08zzXnov/BXZGZQwT3ejdsI7gxfGvQyaC++8xWhI4SY1HF", + "432proSDJcrzllrfo9s8EesAb1dK4MPnkRGi2Ap0hzw8P/9w6ofewpNh48NEfNiDwlaCQMcPfTm85g/r", + "vvinx09CPMzmfdhixmgHpWmeIoT9nQqKtYTOXSm+2mvT+QeQbrIRltT713/8N9DPVld26acipkMrgAyL", + "y9mb/05VdtGCG7mrbwdieF/fXWGFZQNTrM14XRflAJSzmrTUWMM9ibb7a98L/SYQ/PstNVdsrlGxBTfq", + "l40/91fj1n0bf0RWIaQrTJOndFfuq2gKe0Q/RxRP0Zwe9CahVEM+EF0Xw9x5l2wgT5yebXyt2n7ejns4", + "4b7J+DA3F4beA5ezvMEY3pceaYYQ2Qq/RE/4l0XcQr6Legx9caRRcaSGkPrTfOzr4vSdM4Zs/citRJv8", + "wI1d1b3TEbtOICZ3T7tpeQTEd79bfevLofaOqWBQQwHW6tPb3QYrVHGnOdHkTdHuoMCqEv5jDbEqPRXe", + "8Ta+d1oadoPXUyxTKVESAk8+sqjqZx2Td3lPBTRP7Gvi9ntv+Tmin29d8GQzaHmt2pp42GKh1i/r+i3U", + "YoVqbadOZurjG9hGR1RxFZZu3fH0o810ttVGnHMROxzZruZF/A+qz37E+fl3cEE3tx4T9H2WaJYmFOwb", + "KeQdEWqOKQQmkBJKd8Pg7XfWElHkuW4xTaim2xj+Cv9eXOrdJZgFXJx2czH0mZraAMoX2EFncOuBZSWs", + "b0zLEnM9smA+4Bbd/WDH+l1C44+QBLg3s/HM/T5e/Rvs1VG+9FJ53D3lULiw1EfX3sf6+jF4iheN6Fz+", + "UDHdGNDPhUU+GU1iBb6ZhMvpn4l447oFP8fib4bM3FAiKSR0jpGIIouW1KjX9iVmRTSVZh997y0fQirZ", + "JdF0ekE3lf/AMnjpUhJFB8AUSDrKG1CWeloQzFmy3QJsHUemsOZRwqjt/IHbs08/7kGo6MXny+2zdEml", + "pp/1EJQodJiIcJhRoDG2H/WlMHAn5iCu9ucF3RhB4Y80tsIEbOctkdIps7m8bLXKsAqCgYfdElNTx89f", + "YEcAYQ7kGH1xHa7PONpIMV0J719MJb1kIlM10RD0qxm8uCMWcJMaz53GZnVjQj4yO2piRnei+vhyMNB3", + "barQ3p/aboHDPCHAA9ORzv2QnNiUzxWVV4XYvP147Tr5RiJJWExV4UPLe31WKLRecCmN67qbYbQYWHdd", + "StxJlAje8sxxKlLm2IqrZVeRRgXrVTVWfknljGi2sl496waWYm1BgO8LRC6otqzwyDPE2uNEllwAW6VC", + "avQYg5lJa2Kl4lIoysuw0XSVJuiQFkDNIE7XycZN4Dq4lbl1KsUqxWvwYSC1QzS9RJQsK4TeH4Fj4knu", + "t4lodnhn1uHbnRbh5RYTvXWueF4n0T8uc7TYUOONOaexqlbfzfCv//wve2Gux2v+h8GhbDRmZMGF0ixS", + "JzRaivZohFfF6NdmcJhfLCmxDfodx3hbVDwd/YVuWtlHpT72lzvrYzes+H+NTosUm9HbePfDxfVzJAOg", + "O9Lb7NLNDMj8XoROHxxV/+XuT75Hdeu90C+TRKzvgEhruOejM5BEYzbHCvwazb16oKSBkX0QxiPDiiqF", + "TSZ9tV9VLuXrq0E2ExdHd/hIU6VHP4tZd0KzH36iSv9ZzLb94U+uD5iVldoQ6M9ihkEoKcYLrIW8oBLW", + "DLtTElZ/J8CqxaNj15PLGHjPwYEDY0TESKTYqiuVIrKVfJ3exPjI/c0t0gxeYxsTTW1qbnfgus9eusK8", + "N8IIymvcEUeweTSvaMQqdc+bM25iN7ThKlM/Cu/SHbBUFGyql5KqpUDXis/uCd9cKumKZavRXtLno/3o", + "Xgih/5nyw/PyJ7fHy129WIiFbboOtugc+okMNkxLaAVzSjRqrrVHMZxitECPhUG5594FoFqnqahX/o/Y", + "g7b+SSOiu89HRl/D/redsf3MfvmdUPqMYinZB4y/C4yHvq0nDZaxmYtEx/Tgbt+B8300MGuMWLasOsf1", + "/Jvy836zDVFdYid2WzvjAPT+K374gN/3B7/xKu8Dghe26x4YvlVxYweKtxvIHsclXdGYWeOSfqZRtj+y", + "nxVTvHYzPGD9/3Q9poRXU4tXeZGYuyK90pZOPKo30+AjX1W/RouBWeBR+Li7NK4wjJrJuukAnr6LIMij", + "Xz5jTVgbk9j8kpAHMBYlYV0Y4xhsX79LRtdUwipT2jkZ8lr1E+4/l9BX1FC87xMfZxqziJ4dfzPYDuN0", + "a4wnfP9QTsOC8lDDl+58XXz9n++nsz8/y5ntCnnDNany5ULU8mlZ3H9xXfclgNPt7C5rTuXQu8O6U+9F", + "HtlRUC8mLbgMEkexTFXG1LMDHOkAKc1SuvI2rmLDm/dhKj4gel+m0s4+Su0hDmMfZ/YgD9zj2rhHnhr8", + "wD3+0NzDUs5hzONSXLQVz/Typ+AdmEERSgbBZNR+TPiCSpGpwXVwBNzdA0e4Ro6A13f/GIJDnwd+kPd+", + "8pQXro6L0CoK2BepMTM6F5IC08rmdVWfR+YJpbqcfGZbqTQmnn3gFEOSUiqhyNk6x5xWxiEhcUwlCGn+", + "t+97GQ0nnAs+9avoIaQ2iHYIK6F0sin/VPqnC6cbTHgeARX5FvKYPL0USitYL4Wy/54WB5kaGMdZYjQW", + "sTYME+FIXOnPMZzb5HKc+Z9UCjdZ0SkmwaY2E+5afbo4UwVK0zSlUkGp8Scxs84SCop9Hpn1ErKxadm5", + "CeVac6mI8JHtItaQCoe5TjXY3mhyUnjBhpZutpEBRoTgXah74pDekTtW3jEibymNrCCoRqrQ5p4bicLA", + "JSYbwM+ALBaSLhC5sMOjKwzAsMKJS/PvuwAdeHo84THZqCFECVmlNIbH4/E3x4MTIJdUkgUFFRnyJZEU", + "SoHiJFVLoX1onhpOeHGyIWihSYKhVL7Vv7LFECxyR0RKrKHgKXPC54zHBq3H8FGsDVab7eLoUWqWd9so", + "CWyIaaJJg3cAAdUNsT8hTLdkeU1G1QDHOBhwPc/BhQFMf3s8hG+Of8IGt9uJmuaDcJ7m09tO0wyCIIDj", + "rwhLPD7Z9s9DEEl8T7I2OxBd+QAl1NHuwNukxjl1NS2OZpKSi1iseSPBvaKSGVGYhhhSHpmac+x//ed/", + "wXlEONYyM3rrk68mvOikiv3ZxvAt4bEym6XW2v17ZFAgyow8nbroRPX3CV8aRi6pYuoEBE8Ypy8kJdHS", + "8P9H9psXx0OI6UKSmMZbP1rF+cXj4YR7MnyR8eCo6OkQDCBeVL58OgROL6mcplLMaPyCCyOSxxN+as+v", + "shVG/VpNoIBMKTUboT4qQ32UQ72deosvvs2v6SbzAoILtsqm+yCWXBHWdhp5lm+4OCPk1wB9i1xHHpGO", + "PLYcmZ+PyigwCJCUUZA4Va3Zz3in7/zAm+Z7+ULBhwr7G0iRJFl662VgXlar7BaFVO89Fn1X4nyzDTg+", + "whKDTK42wjZy2C76I1drZCeKnOHwUzd6h8h+g6UnsPxFuaW11dLn5sO1kBdTSee2Jz9h2OORKcz+yvvS", + "NgjzfIJdNWA6bcp8EOlkgyURjNQg3G7l7M3p06dPvylK/zds53rr23etK//4+E4LywdwIlhZyQwow/tq", + "jVAfuEEHbrANdAV9J2bsVQ3q9T+2mAOaqLaiRpMC5jQNMQczeCozbjj3Om9UjV/HaHnIzOpd1rEwnvBP", + "vmtrHxVDqml8ZNQrGg8AJzImOP2M79TxGFwUg7KGTr3GDMYAFQpmm+5iNMB/x1PdNGkUKwXu9N8DoIkI", + "/71Y0+c5ckBMU70ElSYMC8Mmvn9Po0GNpuxOYXOOozoLGfT7GEt3atEc0fBWhc1PN49PQgYvz7o2LFgf", + "eGRXHhkuJmT9LJhNz/jiCD0pIb1ai3TkHCzIfXZrT59E+sZ+8B2O/x2h9h9AT6lDP6SpEH7hc129of+g", + "pty00aKKvOaVh3zutYS+WYKib3IXFeIHe1DhGY5/oMK7oUIL/WYqNJB+oMLbMRaQ0sJUaB8MGqlwIUWW", + "Nj8Tnrn2lmbaP+FQ+5rwl49vwa2PGjCWxcncY5sxJ+y8E95XTFPlC08aAMOHcygqoA+GtlwBbp5p9Num", + "maYxrOhqRuWEW0eSj0sI2Q52rQaTwe76Jk0FXGFXOcBzB6w0yZQDjj2zPZ66/dL+FsuYDScu9QC5a4zv", + "/BKIUMyx8JGrqW3/mqNT4YZsqPHha8QZPIX+ivAMi9EY3FNLlmIhYFJG2o0fNeGG4SeCxG5RMzLTwv3X", + "Bd1gVSYQajonK5ZsfIyds4DrHWKb0fijUBaPbyjdFOe2kLjt8hf2WA1BLa7ehQXonVW9sNfpeoXmASwP", + "pNqUL3PbsTUveZnsfJ0PrKTh3xCJNgRsqbCpSKRDsi3ZGKj3WJeRK3HpMpntHvqOf4Br6DCoSK8S3dsY", + "umbCt9UaPenfqwqSD1TQgQpuMcoNkaSxnOIrX0QzZ6WNVRF1tASDTkNQ2cxgiA08iUQi5Bj+wrj1eRYi", + "EoikE16q5BfG9V0yzix8u5h+Q4LUli677cS1VkHq+lndsSDNHGAeGMfvh3HkdfgQdb5QEDOVJmTjKps2", + "ycsjxx6aA9BfKsUWXAGx7jqsXuW0bydEC6GuIMZAISNimZxwL17x7cWGyKOnBrX+Z8fHB8vbXNH+Hlf4", + "vXMie4ordzzEWYDE8R2UnjslHKk2jg1+2J0YXCnrfA8c5ffEUV7iVYaJfhc7OfoFnbgdFXK3CgYBVte5", + "Bn389jjEMDipA8TN6/uO/CUC9UHt30VrtSiOlU3+bELEJoQ/WRFmTkR41JLEdU61NTtLo2GekAXYztBi", + "Pr+6JCxt5PcuDouj3FF1iX2V9Ac6+33ItE9isUjKWnKdICt0vqQk0cu2h87v7IgbxES7QuuLBZWXLMLe", + "AXbD2Ofly9vEg9IWfPC0YWsZJ5eEJWSW0NYWjXks8iOQlMQM/42B1tAnXPDNSmS1Lrs7A0EaIj+CjQr5", + "JZOCrwycDngV1mRxZ9FK5pS7XrQwXvnuO1o1VTLDXlZLd1sdapS19avyl34Tkuk7W1P97npU2RKBO+75", + "7vtSFc0ZZsSG+FgnJUuHkAqph0B1NB7c+uvDd24n9YcHxqHMABoeHcw59i8whmhd2ELlxgYnkiqRXO4o", + "LvZdtQnbmfumi353M3bHbTcgcSdu6UDy7DaTwyuNbbxsw7crX7q9tR68O41NrC71hFgSbUsszyhgDWsz", + "406kC/UIbMK8upmSBawUVI4YX5SVoulKxNTVwCeZogolyUcjmz9hbwxsu6ouWJrnsRZts1U2U4ZXcg2a", + "RRe22g1uKSmyjzBVvNpzCRsZSaqyVW03kDIjSrI0L48DCVEaJI2EjH2q/hguH4+fjo+D5lKGNLWvsXRt", + "xHQzcunuDaZdwsk3EkKUvm26/a7SB6NGkx8NWh8htlHI8RK1T+XoUDFurIbrkQFFGuzRkikt5GZnUFeW", + "GiL7O4Yf/h2jxkY2n8wSUzHj1M1oQybN5kslLsfwmkRLpLiIpDqTvhMXlaOEbKjtAoaZIWgKOY9Flmjm", + "f0eV3BNbG5k5Bfz7fGffuaPeFrFdMYLzyzsN4AyCrlX1K1+7TTYaldLW7wupfTKo5cWEMb7zM44qiNvH", + "wEiH4niOwd71ix3ZdWoBajZ9P5t/uv43U6JB3UUHs9brrHb97MIdd3f6vM2raMpZLde0cUGL1xCV/mR8", + "PBhD7upgCjJO5nNbB/DepkOZ+3hFNWHJTtMzxmHQdxVvVSFMHwVAes9w2bcxzduvhXcPlEsWLZ2rqJu3", + "wsfvBMJobp3x3Iz6eaeNLDu5RZzL/h64Re4X2ruoERcR5l6/bMDIoYou+jlygmku4STFzzTSqqwPYGJC", + "nc9GQqaZGSZFtlgC4RMucBKSFIwXEsrV2OZGI1g/a6y95NtCJkRTpSc8T4CuplHnFWwQAH2eJYmyLXqx", + "6MeEm9GcxoNxHsbuCkA4QxfLPticAWITG6dppCe8mt0IxPycUmkUG7KgQxCcYjueFUmGcGyXtEOZmnAj", + "MIoMDB9igz0gycpJoNkGLihXxAwkiVjk0e8T3s+4//qfNLaT+wpvxsTGfpM+/eTV6/NTTPuY8Dx+/uX5", + "6dhlhyXoK3v94+uz/4MA6+e2wpEx/lMaH1GDiYPhhCsDFKY3I6xKR2ObTYJXymIz6dByWHNRUiRThj2a", + "ha2UOuH+LopSmsU1922zZKGXVK6ZogPrU7CtkCfcmDKYzhQtaXQBItNppvGpzGwJ6OdUKF9114x1pXoQ", + "wQzAZ4ZEjN71/3759Bt7coSUleRM4X1lPCULxtGcRXk9nvCzrd4bzSnzYFPUWsymol7VXapBWuyl4xAe", + "+1t0+UF4u8Bi9bySglTCaCKtOekyiTIs7Wc7d5st3G+1qLild5Sr1qagxd0bPgV9V3/A8qlHpdSYEmwe", + "Ofy7j0k+98p/U9BxDcqGWA2kh7AifFPiIpeMrkPviXXh5apkbOW7hr00YQMBGV0mJeV6asXEC+thsVzO", + "phgNwbPK2QY8+yzKeYIr/btki6X/94rGLFv5/0rE2v1zwjNuTEXLdBOi9BSZoTUiDZcfwyemDU+vEWMk", + "VnTCc8cq46MVXRkxYOWL5atWyDx3kmqZ/wXZZ+m9dwjaLAJzYkTpjEQXWArIsHUzD3NM2Etys66/HUv7", + "IKmtFeTqnODXhhGF2JER7Dk/onV2pKyDPf/kiwpzmnCsaFgSRmMrgqdeNLraz6lAr4rZ3BBSSUfoTDJL", + "Y6m3fURAC+9/gyjXkDF9C8z/e/LZiXyJeG3E5akr/egKPz4+Pv7puX/hgMfHTWy6xdn2OFQH8uYF0f0W", + "KKWrb5Mmb6rFRuva1oO8aJcXGG9AvMnhWHOy8cUNymJEusz5nWLCo0+zkHiF7aIjXUffC7pRvtZsyRKq", + "yRIsqVxKHOcZhnqKuSPVFUkdfVISLcvSpMxYDdN9jfpzzr1RcC6JV5BnlHJwts54wr/FK0X7yQjUlEUX", + "ZtXSp2WtltH1nsWj9tGE3xQwvhMv7U3pjsW5Wmk+H2WRwBgvxcXa63eWz72tanXvmAAaGgVgS+SHkLRa", + "3IoYk1t04AK+/nkjD/hUWkCsiydsEpMUK8f6GeTJVrXs4YRzYyz4IbHTbB3/AiNF5SVJhnmVh5RkysY0", + "qgnv59Zu+TX9kfUl+FVthSbKjf4WlyMGUL2as8VgDC8LD6nINDo77Nd4JOlUYQs652twxeQJ8CxJwJxi", + "is4XomHk+A7hgN6Dg8q3V+np3N/CH4pL5KcKBV/6G3BKDzobS/6sB17Q+8FiIggJ7kXNQabCFD7wnD49", + "SSL6FTjef5lpYf+mWUK7mJDdCtkb2yKTNC83f7VK9rkDcgjO/zj0fj5XsX4Mr8hGWcp0dOxWRp8MmSl8", + "es05lJRk8xzcI+2EkyjKVhk6VYtBMVYgt3U8YCHMludCromMG/rQtJWur6J/Q+X6WzCH/mDl8ENgbayG", + "/3upg38vmYkFYUmMexJHrEcGLTgtvgxykaJM+Qk6cZozjE7Rz5I/DTA+SqWIijj6FYmWjFO58SE/TMQs", + "gkSIFDJl9PV+EU44mktK4dPpx9HMmAKo8qdCanjyZDA0Hyt8DdDCavl5NN/QJfoWpajmkqolhvIlegy1", + "/ra2tZ0h1G0ToVQnH0/elPpkEboYfYpgusVkv2vq4fv4ydedevjeQtV/BOEZXlmw7r8LArO/H1gt7sEy", + "ubsWVy8dS7A6IrNAAabywHjGYZ6wxbLO0s43PFpKwUWmQPBRTFeW3Ev174uZq1GTDRwuZioyis7mRGa8", + "mbl9SCm3b2/n59+BonhtQBaEcWfG4REyhd5arcDF1scTXjC1oa11jT7rRCgajxTVbsMzLKbSF2okaUKJ", + "okPIMGsBSxgwPhdDiOfDUjbDgmrK50JGdAiEjKxnf2hkJF2TJBlgxy3kq2ZB+xCphpClikrt/DvWwpma", + "6eERxJQblpPgWy3CaCzU9P9vbK8kW3FMVMiBWio3PoQ0myVMLc1i9JJyPcvUGON2HHRpbBkzXbHSe/s4", + "B/44fxWfcJLFTANO45iys8OQLxeftLBjv+zmLOMPnPgQDe0cQf6Wz0VQOfPwdUwY/vUf/+2eYjCqNwb7", + "/RsSafX75NB3nkW6zaK/vM0u8jZmqWhRhFdseF/MSGIUz1LyiON1YN8qbz31s8BG5ZJADdjWhsy8p9j8", + "sNUxNSxMPpzDnPEFlalkXEPOb7qLlKK5aavRjQKj6NmI1VC906T0XK/JDB7BX414wCG27dO3m/z1yyu7", + "IqXcBS8XcSuP8h6Sg+fwb856xmQao/raD20AkZk30NCVxAe1cHX28+sCFLfIhmvG7zIP/A/Yv3OSKJpP", + "NRMioYTfMIPNofKOKd3ahlTVG3fcl0atvwf7t/bMVoqgLbCysdjreTZDXSXYT7mUHPdIZgkdwwdOkQAn", + "vER8ZpCnvtLHqOcmYm2b2RWzPAcy4XFmoUZzun52/I1v0u67sLtwgErlBWNDy/GEb5MwflWyb/fqw1yh", + "4t9xkHCpCfOdpE536MocaNv9O9OctrDuXjKJ2685i6K5YADBurPeCrZNgpLtbs8WoCTIyKCP74Vrwi6p", + "HBith7SqKCoivKXgns05t4HEhBurFfr2nc5HLCdiMRPiwmgNA2vZcWwQpGxE2XffvzwdKbbg7pUQfhYz", + "ZFlrIS8wDJZGmVnhkhH4C+WKjMEHsT05flJq/oxfszi3MOx/a0WTOTJSVShxz8FZkUzwCU+wtSfj9UCG", + "o0qfLLN1Mw3nIuNRrjA6MxaMHWsYsA1XIAiJRqeFa4Al5IS7Lk9b/kYo3I3VYC1bbqn09ojGrjlsG2M+", + "j8j/GPv2+mwfA7WzzHbrisPPmQ7pfVFxgyUGgx2Sxw8m7e/T64jMYzf9+iZtNRb82t4+EF4yFyvdpAjv", + "biU6pxbjc9GppUY5IXXLd1dOIFmwS6OHooMNPooUH0ltWG/Ijwb9Egs1/HjCP344/wSNTlJUa803ZspK", + "9MZgPOHPjp851yEXeooXDWwOfTIovKSYeIjCeQj92aAIQja/qCKlMx6atfpR6VMnMmeZhtzsn3AMHxMa", + "aXZDrTvKckxhe/abH62ftZiFYliJEUCIDZTH+NpYfwgq3VOLoVvyl/0Boj7avX/vMJMJqgMf4j2C/jOr", + "gn44Ay6g5DUVa4OnWyoeiUupYqXxcxI5PTH4YMu4pknCFgajj1Bx6dalx3YHta52+5cP5/C2NBlEIklo", + "pFGl8QVODHFxuk42qNYaI1ZIrYaQkuiCLHxtQgwLRm/chPvOTrbuEpxmUgkJLoXJaK+CQ0w1Jl8VKQI2", + "9HrCZxtw5RiGdqvTSMS0iDoeAjbkPcq4ZknZWSXUqAyZBuotn/e1hV2nkm1FiYgrO6iKU/UaNKavnu1S", + "mBqm9kCqTEx5tuqd/K3HLLtKxLo37Nlsjt6wt2QLw6l85kfvp+6LXWtjZLzPa5stQqS7yZZrT+60YMc2", + "Gn/ExssB56Il9yv1Zb7HDZ/Q67fFx8qcrpl3WgOxUjh6f72sPGORh1VkW6F+Zt8/oUFbCmhKRoaw+YRz", + "UTsZdt81ClCaq3qY42RFDPRzPWjCWxQh2NKDBldjpefYD/iPUCVu+1RBnUjpPIDxQf0JqT95eOe25oPQ", + "w9T7Cm7nXzRqPgmLKFet/aXfuSE3iCFuCUSOthQKN67UK7sAwZ+oDSPx0fRJeSz0NaNyCHNKtFWk/pEJ", + "TYyOhVEf1ShgLjSbu5OoI8P6OE1aK9W+L31x6sffIMAC6zU9hbmf69Vj28XVGyFnLI4pL71Ft3/hygf/", + "UC8XXBUrZciCByz0FY0kxZifmBgdtq1UVHmKDuVkA5C6oeKygZXups1f6MgtiOF9dO4WSpdwJeXm1hEs", + "r/MaQrKuCLVdEiXMDDpWJQtj3z3sLXc3V5Z3SrvKle0uSHZvbuH4jmj8sDt2+k77F++FflPEV10HUvgy", + "XiGcCDCpfSVFSymvu8STW5FHd9MtryOu+vKyzVd9W/LojhA/b/92awLsRFOrN4X1p09U6fsswT7he35C", + "pYaYJuyyKJ3wx0WSc8pjIKA2XC+pZhHoAgi+vpp3Te+BNprWDENJVzRmltwdd2rzieeDfRiOfQysRt8N", + "81oxycaViHEFDnzsfe6nxjdELDb2HCJbGYEYm27FdJE9+aToPjjhpQ3XSxaUfgo5XmzzjXzImT9tJy+2", + "3XnQO5xSHjO+mNoQNmLuwkezIW3YYmm9YS+Wm6nM+NTH8PeGPRvfYWjB/9t+JJKExtMZwQQpFy7c3b98", + "jS53dzvX7A9GF/Ctun23L77JkA4g+b2KLq0TwA4PrwwcB/pz7PYvZE6ebQpWfcUuwaB1ogjtox4dCoTH", + "/v3M1XHypQZrVTTK9cPYXBdhpX7qCW8JHoWOsaM2ISgQPAqflkzB+9c/vj6zRY3KxTEbOFU9uHQHs3JI", + "WkLGG3JnbBPG3XgztvfRFADqHRkhjOo7vAOPd4Pf3dNNAG0eokOL6NDQrV8tTrQ8Y3/OPg8cX5qXineV", + "IkUNn8TUu5GPIPhbmUOO8/hkI7C7sVSP7u1q2dEvsvWtDePZO6pHoVepAAV2sQDkPXafdGcqAbS6x2L+", + "lnlASO9vKtf0J1qnKZkjU3cFoxMpHClN0/bcMTPCxk2X83DhZ5FJThLon9o8zKOXaZpsjlwJcHp0KlZY", + "L1JkOhIrqga+GhvGWpRLHgNTPkg7hn6uzrvMkwn/kFKOOWmP/EtVbHYSXeT91MMUa5+T97NozhEcfyCa", + "NQdqUtPxsEPMtDZ3Z+voPZDsISTr8r8CNPuFCpMNEt7g+in6xBnOzRkXW3bFyJXV9Sa3V+MvGV1TCatM", + "aYjZfE5lXv7IFjOxWn5fUUMuNrpuDnGmGVVDYw8ECdStUk5A2EGjL+0XVUX+lij0hq0FA2KDe7/dCwnu", + "EeB+i3K3y3vMGm5Zr38vtGHittRRjbZtOUV0Enp6dhmdOQHXWJmjtsIB0aiJtKnwufuuowbvuVZHJhfL", + "zai1iIor8aN8He7CjqEYQwxr7EIpsiT2EXt5dTgjDBlfjCfcRdGMFsZaPvEZVVV0dKoLvHz/ynPG/Mep", + "02YE9/E0z0v+kie+ZAAWoNLMukXohBdfzUS88RfWqAd1d4m8wjIld8JIS8zt2W0Wl/CBULGgNnHDNWyC", + "4DXdHy7n0arK5b68zd777zyW1fCR28A6YKs0oSvKUW8vYadVzusBDJZi686C4rOaLuTopcomShfVja04", + "KHZlK354I1sxRo7vsjNnn3dYP9gBrcxB8hpwzSwkeNo617Bu1SrbgH25RncF7LXd3wPfeOAbt843HO7V", + "+MZWNbR7xkjsk+UBNph/69zXBttlbe3xamI28GBr3by31D3LPdha/3NtLUts99vUkiJJMIqikZmdUexI", + "p6pl+VJJR65BfYMZVXvfdet014IsE7yiFtTdVXzmNvigBv0O1KAcm/4oepDBPttZjPDiueQ2bamcEVQZ", + "B7pTOqUvnjnXS8JmksjNiasGuKDckBn1vhkfKTThGCrktZpQhx23ekOOoFuvd6Py3ixhH5Gak7GsvK8c", + "3lLK41unFGapuBKR8/vIs62jiYJ+OaJsEELLPPekFTfzGir2K5htgMVDx+mMVNZ501X48/mH97b/HtYM", + "vhpq3l0X/uumgHasf0D2e5Uwa69sR3N4wXN66GOFNKZVmQh8h4cg3Z14Ym1z0GMbXAx0FIzrEeNYZmGr", + "OYy1d32uakw0mfB+vfto3p/Zd6p+BL6oxND3bsu4HoIWqS0SkvcUtDXlnAbLNHat5sBWvku4PZJVLr//", + "8eOE+7MpEDyxZYmcKL2keXNbjH9y/b1VHs9Yqr/WiVN8xG7v5uc/eYAebig7riBm6JoIGsKPb4kt1Hn5", + "A4PYUT2tFh9koQcE3mAQcqmE8+scE8/d+Ap5impr3225JGwn1hsTFy/jFeO4SpvCZAbU07DvwqAQCS1i", + "L2r6yCxjieFaIB3MGnXoyiyVqzixgbjNeUXIAMxIF1F8M26y00xpsTLr2GXuqG5tsY3W9uI4CqHu45hL", + "Acq3gyUf8/v1hSdu3RGFJMJi0OSC8qb87qiA1S4E3c6G69YH3NpwfymaZufKQqnBR5F2IOmcSsojqiac", + "YH5W2YR1RxjmFd7MjCNnMsFMirWy/ZlsVdnxhJ/5+VAzsKVjIyHTTGGBP000iwbDIoUqShjleqRYTHOp", + "bObaUt/N4ZuU9+yGmaRZoClo75NvKv5gSNa4dX6NOwxJgyIlfPXYhaiDhWHKvd/bIvTyBStkk9c73ukP", + "KUocu7YBleIz9YTAGYkuGF/Yp1/sE+q/iiVLklEs1hyIdqQBffo5tQSG6q8WoCg1dGnxXQ3GPoXQXGVR", + "K3i7Yt7MlyudEp0XfQ/RjKfJEM2cI1T2rHZ37e0ZWvPpSvXVHu+sr7bVOjEHEYi5q6xoO2jkqRWKUg79", + "szenT58+/WbwHMSK2XvRRGpzc9jrGu+8obVioLBcpzp1N2n/m4tt41WuJQIWrPUIe6V6cA+sronV3eO+", + "89XKxVuuu4N4bGfnXkS4w0DLbVdUE9QW0iTLW9JkCf1CQZxJY/RP+CWVMYt8lxyiLQL3ffZ1URC0otqo", + "IRYIntJLFhulZOCe8ckasK+ja41mMOr9h0/AeMI4jWFJJX0Oc/S7MD3hKbU53a5SHgU/X6lycJAN23es", + "XXz4j+B2NOd4RTVhSRPjwQuL3ZAHxnGPGAfWvG9iHB849RSb0+kjzF1GirBN4AwDEY58cuK4Chs5Eioi", + "SQsz4TGV6Bg0FykddpHS28GH89OX7+Dx+Hj8FbxUiiq1olyD7SyqJjwWUYZ/6RsFb87se/8jEDNF5aXV", + "tDzdD4YgaSS40jLDGJG5FCur+DkGZddHp2XR1+ILBfRzKqSmMu9hYQMHbDvnCSdSszmps7UGZtJJp8Nj", + "/96ZyQdz+a/cBYUwtv1y0R/+wGN+PzzmJayXIikRMd9BvuCp9yosBl0KR7+Y/3sb/3bk2dZODQZfYirq", + "SaEPuBYQZmkkd6e2TLgVfEP3dGnYiA3KoXIUiZVtJ2E0EgV999/DPHQHkzbTTA+BfmYabAl0+jnFeLUj", + "EumMJAPbZLWq/biqDr7sQyr/P/aurbdtHQn/lUH2YW3Udt2enKJIn3rO6WILtE1QN32pCoOR6JgbmdSS", + "klMvcP77gjOkRMmyYiexk17e2pgSb3P5RM58o9QMLvhcyCQcHb5MGayAk66gsLYVagtGNBaXZTGJUqJ6", + "PuBzSBNsVi80ORb69+uDpb37O5izj0XK3/iNOWC1m0bEEIrItiVunv/+4iFLuK4tW8ehlfVQZbNf9vKx", + "2UtHJdWJzdw24kFZixkiDQ1OpDK2ShVL+vdoObfDaoHZdNfWLhXdwbacyYSltlVg+yPZYfw7oFt/QKw7", + "dcgGOyC2O52sWf07GBL7AWzWL8D30xuw0BjsEf4Z9TTTytpJ3XnxPpmcnpXt9umtq342ndr632/Ng75+", + "EDiZnEK5DNArP+wt/nvl7uTAkZhKvrSfwQh+u0nVWLIQ8sQYNfXv7iY4D+a+p4v0oIcHvUkPZ9q1w+v3", + "5/tkAm29o5Y14dhxtzdp2lMurc9NttW4N675fhXP9bKxEjf9HCqKSOAJWAeLmtJvUy0304aK+ftzIy7l", + "UEjIRHzFNfSYVHK1UM0yDvXF246wva5NPwBR+440ta307HVphl7MTMwSDCw0YMFyLnLBDTyhZCGzu3nb", + "Qpy/+7P97azX4Xf5s+DX+F1R3+SaO7uFwyra/FXxIDu6V5dIrN07ucSDC5UrKfhoybFbadCbVgcjCwhV", + "TR2qyhVccZ65aGVhkGFNSd6/g8fFgpxPY2JaEUuRr+x/ZuKy0+3iU38GD/1Jz+xx79d76w4wpFBzmgs8", + "ARcjYsAUF0MfPv2gH1y4hjfEOpUlPsMNGi6UFDmVHcZo+3KSF+yKJxYq+NkGpmkt+NEgSaiLV3HvGCDY", + "YCn9XchhplXMjYFULLm0/yiLdOYKPvJUsYQOkTkSHtOkRvSyEXHyJCP4w8q9AaY5LB27RhJJFy0zZzKx", + "y3JhGzG9omq8RT5Us6FGUp8lSwtk27CfE3A8Hoe0F67ebrhArVH4RbfU7iEgd72jA5vLTSPYUIiCpKhe", + "lO47Igx2CnVD3L0zt1vo04120nHh72QnJ54//yCb7nprWbn3PNciNo+kBOE92MLAVi3c3J7Aggk7F7xZ", + "mqWsdUvLWuXrfm9z1GdG5pMnVdFlUjDoKQ1/kfWt6jFfz7nEUpxaXTt25j6cvTuf4MvWrHbgo8AQ9dn5", + "WyzHCRoPxoFBZCWIMIF/LDoCNpspneB8SyY00NawDnMtslEk3wu7MyY0nWGlzqGzAstnozYrWy6Wa7fp", + "OBtbN5Zmn0Lf6OpnQganE6hq85el9feADaBnBUUvWWrxKPz2YjwejV6Mj1+OxwPQLOdTjMyN5LPR6Hf7", + "NyrRPVVyihGCU1ciAC6USjmTg1A9p5epumBpJN2PyDS8EVF49g7DFhzQ4EcyIGilgL3AIlTLkgm7wkVW", + "zo2wRQlGcpWBmlHqA/+WQy7iK6pgrmCu8qFGyONQEkjOE57cUk9KRNKmJ/cPR5q9HBiLtHb/QwMRmsaW", + "eGQrLe52X+aa82xzru6p5EM1m8GCyYKlgK2hMBScYUU/OvpYSJDquu5HPEXLhOf2e5MKgY/gjcRgeUp2", + "HFVj/4+6oJNLqeTQVUoYWN8nh6E3xpRauJ4r4/49Umbq30Ih+G8n8OH83btRJP+NjX0IftUKPyg+nH4C", + "zYecxpO4oDmMYAFmnWYez4dFNqALNzu0GFNWhzMhL7nONEXiBs4ds4xBzSKJkynfXLpisJ4Yt0TT9bAz", + "O7e0A8o0DcEEt/IQ2og9dblLbGDXHCsJPvgt5S6q5UTURbDXZRRFlKLGMbUGE96suEFTDtuUrlYE/law", + "MaxbvY4cw18jadEj3AE8RtKJ7C3AYyQD9Agt4DEE45tQYwvA7ASO64tzdKB67T8lfKzXUD8ggrQA8uWL", + "4xb8+Nz+bR0eQgMdRnJLeAhNdBjJXeAh1NBhJEt4GK/ilN8CH26pESVE3KAR948SWzo6MFDcNIJfWDHA", + "ilupbJvnMjGTt/NYk5jJdU+FETuBg4rkLY83rIOK5J2PNzBpSHzDGGlilfEmh46fEa0lLCOeF79w/zRI", + "PhjJlCV4+YJxflmKyUexQI6AfxyfuN3yR+UEKDOVihjre/J+i6Zjku8WPq9a3qM9x/n+fD6u3PBGCmOg", + "Offn5EbwjoRoISRxI2kOf757/f7szV9WGFUkv/w+gOcvX46/UjGD0vXhz/Dl2QCejcdf7Q9zjik+smSa", + "jWSPuEhp94DHc+VJO1O2yHjiXFb/FV3J3LuL1HymuZn7Tmnd1k5PIon+8cXY4BFKGAa7lV6Unq+hF3u4", + "2646OPRtdqPndg/Xc/va/yl93U7qu9Hh+YbDjEh9O1wfS4aY0OJKrrp6SEhnUHU/9S8E+5noDMC3PJLP", + "j2GuCm2g9+H0EzBwJU7K7+f+CXom2waSgnuiMxfpFRyylB0Y69hcCpBSEomT+be8HEEytTMc0OP+5MId", + "r5QnJgWnS1qkNCDPxyQRQkDCs3x+R8c1cYM5c8u7Z6VpdtcaSezWr9rH78BjPT+eW1m4ZjppCCDZ2nbx", + "3yj2S6YFu+giADqVHLjM9QrTVn17d/lvcjuBxFGpEB8PJY6lKwvVPAkQ9K6QicXMRWZgoTSveLVK5IBe", + "rCL2iWRhuHkFhSwoh8w5SoupUgSc9huPxXM3vJhp7Wp2RLL5dlIPkk+lUSe1SDjhUsPd76gcRFgkEmMX", + "lc1mjr69SLmpVISkvtB8uqB7Q1gwTbwtSl8yKf6H8jI0GY/FTMQWKcZ8rlLr9ku4KnvarEyqLqeaL1TO", + "p4brJdcDiOdaydVU5tk0UyodwAWTkutpzr/lfczojaSfjAEzx/pGLL1mK+NIyXd2pzVt/VyKxZ71tOyo", + "E2yiPAxRDEqBBaO0yxvEwLjHr7olK1EczOeqZCga5nyRpdajVXPEM8dSQazwecntAJ3nn0BzlDcCYP/C", + "M0sv8QuWQY9dGIve7cKhwHCduwpa66pj+iPwpY1L9acne1YXgwsCiwe9SaBJ9lFlyyIIOJ/j8dg+7xl3", + "1WxGrPiRvOKrV9UMgf+3YKmv7NUcFr440SqzgNbqg4OoYsE7Lwbp5o++3lg+BzqOcamsoR3Dsdo/Lri+", + "rEmeK+eF8BUT63d2jTXsWle2/cBX38dpJT6HR7IbBrGeQxpQytXkFqkMrpmpzhx+bIT7kdS47rtKHQxM", + "QeDdc2VNSlfI0euzt5+o0T4JQDOBnWzkjLI/3mPq0euzt0BTX8s7otBYs0PGEb7IZXl1ZRr5ldyT4vo1", + "fNAco/ogks176ZKMXhGhI7v2iV7CQKY5+hs8UUAaOnc2YTfoYbOSSrmhjOZqzF5OQMmYD5C76cbAf5Kb", + "dYpNEswtE24CoXoc2TYf+VJd8QR6IuGLTFmR6t95B+iltR24cWHduoUrW5gbEi7PzZ4zLbGDmypD2EaP", + "gOjYrtZGouPCrdSmPQge7rKJ1YLfv0G0735QY2gHcOM+PxRbsatKiccBurx0YCItNH8EcleZxRYGY9vi", + "Jtlbt6sosluaVbs1D8eId1MS4wEz6FFGN2XOT9QsdzFZW+6KN8qDThP8I3ARbqP8j2s3qcTHdvu4xmlQ", + "KZcjsWfGiEvZTWKPa2Rbv6bG329Co58JTWQnf9OGoVTKgRbwATyDP77RRCbfpEnDYeE1ecohVzcLDDLJ", + "O3HoFJlC7iQ05775L7EJxEbzhVqu4e8Gml5QLX/cQuQkuvUmLrmmFptB9WfXZI/G1nXRZW9dk4o/lO7I", + "hIGEZ6laOdKfwZHhcaFFvjo6+fI1XLU/CpEmfr7Va+rsCfi8XnpBbJT+UzFLIeGoeo45ptDp0cnRPM8z", + "c/L0aWpbYLWXl8fHvx39/fXv/wcAAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/api_helpers_test.go b/internal/server/api_helpers_test.go index ea899a2f..e6e65b92 100644 --- a/internal/server/api_helpers_test.go +++ b/internal/server/api_helpers_test.go @@ -31,6 +31,7 @@ import ( "github.com/Hanalyx/openwatch/internal/license" "github.com/Hanalyx/openwatch/internal/liveness" "github.com/Hanalyx/openwatch/internal/notification" + "github.com/Hanalyx/openwatch/internal/remediation" "github.com/Hanalyx/openwatch/internal/scanresult" "github.com/Hanalyx/openwatch/internal/scheduler" "github.com/Hanalyx/openwatch/internal/secretkey" @@ -294,6 +295,9 @@ func freshAPIServer(t *testing.T) (string, *pgxpool.Pool) { } s.WithScanQueue(scanKey) s.WithExceptions(exception.NewService(pool, audit.Emit)) + // Spec api-remediation: wire the remediation governance service so + // /api/v1/remediation/* reaches a real handler instead of the 503 guard. + s.WithRemediation(remediation.NewService(pool, audit.Emit)) // Spec api-groups: wire the host-group service so /api/v1/groups and // its sub-routes reach a real handler instead of the 503 not-wired // guard. diff --git a/internal/server/api_remediation_test.go b/internal/server/api_remediation_test.go new file mode 100644 index 00000000..5dff38e6 --- /dev/null +++ b/internal/server/api_remediation_test.go @@ -0,0 +1,206 @@ +// @spec api-remediation +// +// Endpoint AC (DSN-gated). Service-level AC-01..04 live in +// internal/remediation. +// +// AC-05 TestAPI_Remediation_LifecycleAndRBAC +// AC-06 TestAPI_Remediation_LicenseGate +package server + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/license" +) + +type apiRem struct { + ID string `json:"id"` + Status string `json:"status"` + RuleID string `json:"rule_id"` + HostName string `json:"host_name"` +} + +// @ac AC-05 +// AC-05: RBAC bars on the endpoints + a full request->approve happy path +// through HTTP; ops_lead (request, no approve) is 403 on :approve; +// security_admin approves (different user); separation of duties holds at the +// HTTP layer; reads (get, steps, list) require remediation:read. +func TestAPI_Remediation_LifecycleAndRBAC(t *testing.T) { + t.Run("api-remediation/AC-05", func(t *testing.T) { + url, pool := freshAPIServer(t) + hostID := seedHostForIntel(t, pool) + + base := url + "/api/v1/remediation/requests" + reqBody := map[string]any{"host_id": hostID.String(), "rule_id": "sshd-permit-root-no"} + + // --- request: ops_lead can, viewer cannot, unknown host 404 --- + vr := doReq(t, asRole(t, "POST", base, auth.RoleViewer, reqBody)) + vr.Body.Close() + if vr.StatusCode != http.StatusForbidden { + t.Fatalf("viewer request status = %d, want 403", vr.StatusCode) + } + + ghostBody := map[string]any{"host_id": uuid.Must(uuid.NewV7()).String(), "rule_id": "r"} + gr := doReq(t, asRole(t, "POST", base, auth.RoleOpsLead, ghostBody)) + gr.Body.Close() + if gr.StatusCode != http.StatusNotFound { + t.Fatalf("unknown-host request status = %d, want 404", gr.StatusCode) + } + + or := doReq(t, asRole(t, "POST", base, auth.RoleOpsLead, reqBody)) + defer or.Body.Close() + if or.StatusCode != http.StatusCreated { + t.Fatalf("ops_lead request status = %d, want 201", or.StatusCode) + } + var created apiRem + if err := json.NewDecoder(or.Body).Decode(&created); err != nil { + t.Fatalf("decode created: %v", err) + } + if created.Status != "pending_approval" || created.RuleID != "sshd-permit-root-no" { + t.Errorf("created = %+v", created) + } + + // --- duplicate open -> 409 --- + dr := doReq(t, asRole(t, "POST", base, auth.RoleOpsLead, reqBody)) + dr.Body.Close() + if dr.StatusCode != http.StatusConflict { + t.Errorf("duplicate request status = %d, want 409", dr.StatusCode) + } + + // --- anonymous list rejected --- + anon, _ := http.NewRequest("GET", base, nil) + ar, _ := http.DefaultClient.Do(anon) + ar.Body.Close() + if ar.StatusCode != http.StatusUnauthorized && ar.StatusCode != http.StatusForbidden { + t.Errorf("anonymous list status = %d, want 401/403", ar.StatusCode) + } + + // --- approve: ops_lead is 403 (no remediation:approve) --- + oa := doReq(t, asRole(t, "POST", base+"/"+created.ID+":approve", auth.RoleOpsLead, map[string]any{})) + oa.Body.Close() + if oa.StatusCode != http.StatusForbidden { + t.Fatalf("ops_lead approve status = %d, want 403", oa.StatusCode) + } + + // --- approve: security_admin (different user from the ops_lead + // requester) succeeds --- + sa := doReq(t, asRole(t, "POST", base+"/"+created.ID+":approve", + auth.RoleSecurityAdmin, map[string]any{"note": "reviewed"})) + defer sa.Body.Close() + if sa.StatusCode != http.StatusOK { + t.Fatalf("security_admin approve status = %d, want 200", sa.StatusCode) + } + var approved apiRem + _ = json.NewDecoder(sa.Body).Decode(&approved) + if approved.Status != "approved" { + t.Errorf("approved status = %q, want approved", approved.Status) + } + + // --- re-approve -> 409 wrong state --- + ra := doReq(t, asRole(t, "POST", base+"/"+created.ID+":approve", auth.RoleSecurityAdmin, map[string]any{})) + ra.Body.Close() + if ra.StatusCode != http.StatusConflict { + t.Errorf("re-approve status = %d, want 409", ra.StatusCode) + } + + // --- separation of duties at the HTTP layer: a security_admin + // requests, then tries to approve their own -> 409 self_review --- + selfReq := doReq(t, asRole(t, "POST", base, auth.RoleSecurityAdmin, + map[string]any{"host_id": hostID.String(), "rule_id": "self-rule"})) + defer selfReq.Body.Close() + var selfRR apiRem + _ = json.NewDecoder(selfReq.Body).Decode(&selfRR) + selfAp := doReq(t, asRole(t, "POST", base+"/"+selfRR.ID+":approve", auth.RoleSecurityAdmin, map[string]any{})) + selfAp.Body.Close() + if selfAp.StatusCode != http.StatusConflict { + t.Errorf("self-approve status = %d, want 409 (separation of duties)", selfAp.StatusCode) + } + + // --- reads: get + steps require remediation:read (viewer has it) --- + g := doReq(t, asRole(t, "GET", base+"/"+created.ID, auth.RoleViewer, nil)) + g.Body.Close() + if g.StatusCode != http.StatusOK { + t.Errorf("get status = %d, want 200", g.StatusCode) + } + st := doReq(t, asRole(t, "GET", base+"/"+created.ID+"/steps", auth.RoleViewer, nil)) + defer st.Body.Close() + var steps struct { + Steps []any `json:"steps"` + } + _ = json.NewDecoder(st.Body).Decode(&steps) + if st.StatusCode != http.StatusOK || len(steps.Steps) != 0 { + t.Errorf("steps status = %d len = %d, want 200 / 0 (empty in free build)", st.StatusCode, len(steps.Steps)) + } + + // --- list: remediation:read, host_name joined --- + lr := doReq(t, asRole(t, "GET", base, auth.RoleViewer, nil)) + defer lr.Body.Close() + var list struct { + Requests []apiRem `json:"requests"` + } + _ = json.NewDecoder(lr.Body).Decode(&list) + if len(list.Requests) < 1 { + t.Errorf("list len = %d, want >= 1", len(list.Requests)) + } + if list.Requests[0].HostName == "" { + t.Errorf("list request host_name empty; want the joined hostname") + } + }) +} + +// @ac AC-06 +// AC-06: the license gate on the act verbs. ops_lead lacks remediation:execute +// (403, RBAC fails first); security_admin has it but the free tier lacks +// remediation_execution (402 license.feature_unavailable); once the feature is +// licensed the gate opens and the not-yet-built body reports 501. +func TestAPI_Remediation_LicenseGate(t *testing.T) { + t.Run("api-remediation/AC-06", func(t *testing.T) { + url, pool := freshAPIServer(t) + hostID := seedHostForIntel(t, pool) + base := url + "/api/v1/remediation/requests" + + or := doReq(t, asRole(t, "POST", base, auth.RoleOpsLead, + map[string]any{"host_id": hostID.String(), "rule_id": "rule-x"})) + var created apiRem + _ = json.NewDecoder(or.Body).Decode(&created) + or.Body.Close() + execURL := base + "/" + created.ID + ":execute" + + // ops_lead lacks remediation:execute -> 403 (RBAC fails before license). + o := doReq(t, asRole(t, "POST", execURL, auth.RoleOpsLead, map[string]any{})) + o.Body.Close() + if o.StatusCode != http.StatusForbidden { + t.Fatalf("ops_lead execute status = %d, want 403", o.StatusCode) + } + + // security_admin has the perm; free tier -> 402 license.feature_unavailable. + f := doReq(t, asRole(t, "POST", execURL, auth.RoleSecurityAdmin, map[string]any{})) + body, _ := io.ReadAll(f.Body) + f.Body.Close() + if f.StatusCode != http.StatusPaymentRequired { + t.Fatalf("free-tier execute status = %d, want 402; body=%s", f.StatusCode, body) + } + if !strings.Contains(string(body), "license.feature_unavailable") { + t.Errorf("402 body lacks license.feature_unavailable: %s", body) + } + + // With remediation_execution licensed, the gate opens; the core does + // not implement the host-mutating body -> 501. + if _, err := license.LoadJWT(mintTestLicenseJWT(t, []string{"remediation_execution"}), license.VerifyOptions{}); err != nil { + t.Fatalf("load license: %v", err) + } + t.Cleanup(license.Reset) + l := doReq(t, asRole(t, "POST", execURL, auth.RoleSecurityAdmin, map[string]any{})) + l.Body.Close() + if l.StatusCode != http.StatusNotImplemented { + t.Errorf("licensed execute status = %d, want 501", l.StatusCode) + } + }) +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 0b404688..9271e094 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -29,6 +29,7 @@ import ( "github.com/Hanalyx/openwatch/internal/notification" "github.com/Hanalyx/openwatch/internal/policy" "github.com/Hanalyx/openwatch/internal/queue" + "github.com/Hanalyx/openwatch/internal/remediation" "github.com/Hanalyx/openwatch/internal/report" "github.com/Hanalyx/openwatch/internal/scanresult" "github.com/Hanalyx/openwatch/internal/server/api" @@ -90,6 +91,11 @@ type handlers struct { // (*Server).WithExceptions; nil makes the exception endpoints 503. exceptionSvc *exception.Service + // Remediation governance service (free core: request/approve/reject + + // projected lift). Set via (*Server).WithRemediation; nil makes the + // remediation endpoints 503. Spec api-remediation. + remediationSvc *remediation.Service + // Host group service (sites + OS categories). Set via // (*Server).WithGroups; nil makes the group endpoints 503. // Spec api-groups. diff --git a/internal/server/remediation_handlers.go b/internal/server/remediation_handlers.go new file mode 100644 index 00000000..db2b7e0c --- /dev/null +++ b/internal/server/remediation_handlers.go @@ -0,0 +1,320 @@ +// Remediation governance HTTP surface (free / OpenWatch Core): list / get / +// request / approve / reject / steps. Thin handlers over internal/remediation - +// RBAC + the host 404 pre-read + error-to-status mapping live here; the +// lifecycle invariants (one-open, state guards, separation of duties, the +// never-touch-a-host invariant) live in the service. +// +// The act verbs (:dry-run, :execute, :rollback) are OpenWatch+ licensed: they +// enforce the dangerous, license-gated remediation:execute / remediation:rollback +// permission (403 then 402 via the RBAC+license middleware) and, for an entitled +// caller, report 501 because the host-mutating body is the licensed track +// (docs/engineering/remediation_licensed_plan.md), not built in the core. +// +// Spec: specs/api/remediation.spec.yaml + +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/google/uuid" + openapitypes "github.com/oapi-codegen/runtime/types" + + "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/host" + "github.com/Hanalyx/openwatch/internal/remediation" + "github.com/Hanalyx/openwatch/internal/server/api" +) + +// toAPIRemediation maps a service request to the wire shape. +func toAPIRemediation(rq remediation.Request) api.RemediationRequest { + rebootReq := rq.RebootRequired + txnal := rq.Transactional + out := api.RemediationRequest{ + Id: openapitypes.UUID(rq.ID), + HostId: openapitypes.UUID(rq.HostID), + RuleId: rq.RuleID, + Status: api.RemediationRequestStatus(rq.Status), + RequestedBy: openapitypes.UUID(rq.RequestedBy), + RequestedAt: rq.RequestedAt, + ReviewedAt: rq.ReviewedAt, + RebootRequired: &rebootReq, + Transactional: &txnal, + } + if rq.ReviewedBy != nil { + rb := openapitypes.UUID(*rq.ReviewedBy) + out.ReviewedBy = &rb + } + if rq.ReviewNote != "" { + n := rq.ReviewNote + out.ReviewNote = &n + } + if rq.HostName != "" { + hn := rq.HostName + out.HostName = &hn + } + if rq.Mechanism != "" { + m := rq.Mechanism + out.Mechanism = &m + } + if rq.ScanRunID != nil { + sr := openapitypes.UUID(*rq.ScanRunID) + out.ScanRunId = &sr + } + if rq.Projected.CIS != nil || rq.Projected.STIG != nil || rq.Projected.NIST != nil { + out.ProjectedLift = &api.ProjectedLift{ + Cis: rq.Projected.CIS, + Stig: rq.Projected.STIG, + Nist: rq.Projected.NIST, + } + } + return out +} + +func toAPIStep(st remediation.Step) api.RemediationStep { + out := api.RemediationStep{ + Id: openapitypes.UUID(st.ID), + RuleId: st.RuleID, + DryRun: st.DryRun, + AppliedAt: st.AppliedAt, + } + if st.Mechanism != "" { + m := st.Mechanism + out.Mechanism = &m + } + if st.PhaseResult != nil { + pr := api.RemediationStepPhaseResult(*st.PhaseResult) + out.PhaseResult = &pr + } + return out +} + +func writeRemediationList(w http.ResponseWriter, items []remediation.Request) { + resp := api.RemediationRequestList{Requests: []api.RemediationRequest{}} + for _, rq := range items { + resp.Requests = append(resp.Requests, toAPIRemediation(rq)) + } + writeJSON(w, http.StatusOK, resp) +} + +// remediationSvcReady guards every handler: 503 when the service is not wired. +func (h *handlers) remediationSvcReady(w http.ResponseWriter) bool { + if h.remediationSvc == nil { + writeError(w, http.StatusServiceUnavailable, "server.unavailable", "server", + "remediation service not wired", true) + return false + } + return true +} + +// mapRemediationErr translates a service error to an HTTP response. Returns +// true when it handled (wrote) the error. +func mapRemediationErr(w http.ResponseWriter, err error) bool { + switch { + case err == nil: + return false + case errors.Is(err, remediation.ErrNotFound): + writeError(w, http.StatusNotFound, "remediation.not_found", "client", + "remediation request not found", false) + case errors.Is(err, remediation.ErrDuplicateOpen): + writeError(w, http.StatusConflict, "remediation.already_open", "client", + "an open remediation request already exists for this host and rule", false) + case errors.Is(err, remediation.ErrWrongState): + writeError(w, http.StatusConflict, "remediation.wrong_state", "client", + "action not valid for the request's current state", false) + case errors.Is(err, remediation.ErrSelfReview): + writeError(w, http.StatusConflict, "remediation.self_review", "client", + "the requester cannot review their own request", false) + case errors.Is(err, remediation.ErrInvalidInput): + writeError(w, http.StatusBadRequest, "validation.field_required", "client", + "rule_id is required", false) + default: + writeError(w, http.StatusInternalServerError, "server.error", "server", + "remediation operation failed", true) + } + return true +} + +// ListRemediationRequests implements api.ServerInterface. +// Spec api-remediation AC-05. +func (h *handlers) ListRemediationRequests( + w http.ResponseWriter, r *http.Request, params api.ListRemediationRequestsParams, +) { + if denied := auth.EnforcePermission(w, r, auth.RemediationRead); denied { + return + } + if !h.remediationSvcReady(w) { + return + } + f := remediation.ListFilter{} + if params.Status != nil { + f.Status = remediation.Status(*params.Status) + } + if params.HostId != nil { + hid := uuid.UUID(*params.HostId) + f.HostID = &hid + } + if params.RuleId != nil { + f.RuleID = *params.RuleId + } + if params.Limit != nil { + f.Limit = *params.Limit + } + items, err := h.remediationSvc.ListRequests(r.Context(), f) + if mapRemediationErr(w, err) { + return + } + writeRemediationList(w, items) +} + +// RequestRemediation implements api.ServerInterface. +// Spec api-remediation AC-01, AC-05. +func (h *handlers) RequestRemediation(w http.ResponseWriter, r *http.Request) { + if denied := auth.EnforcePermission(w, r, auth.RemediationRequest); denied { + return + } + if !h.remediationSvcReady(w) { + return + } + var req api.RemediationRequestCreate + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "validation.field_required", "client", + "malformed request body", false) + return + } + ctx := r.Context() + hostID := uuid.UUID(req.HostId) + if _, err := h.hosts.GetByID(ctx, hostID); err != nil { + if errors.Is(err, host.ErrHostNotFound) { + writeError(w, http.StatusNotFound, "hosts.not_found", "client", "host not found", false) + return + } + writeError(w, http.StatusInternalServerError, "server.error", "server", "lookup failed", true) + return + } + requestedBy, ok := h.reviewerID(w, r) + if !ok { + return + } + var scanRunID *uuid.UUID + if req.ScanRunId != nil { + s := uuid.UUID(*req.ScanRunId) + scanRunID = &s + } + rq, err := h.remediationSvc.Request(ctx, hostID, req.RuleId, scanRunID, requestedBy) + if mapRemediationErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, toAPIRemediation(rq)) +} + +// GetRemediationRequest implements api.ServerInterface. +// Spec api-remediation AC-05. +func (h *handlers) GetRemediationRequest(w http.ResponseWriter, r *http.Request, rid openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.RemediationRead); denied { + return + } + if !h.remediationSvcReady(w) { + return + } + rq, err := h.remediationSvc.Get(r.Context(), uuid.UUID(rid)) + if mapRemediationErr(w, err) { + return + } + writeJSON(w, http.StatusOK, toAPIRemediation(rq)) +} + +// ListRemediationSteps implements api.ServerInterface. +// Spec api-remediation AC-05. +func (h *handlers) ListRemediationSteps(w http.ResponseWriter, r *http.Request, rid openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.RemediationRead); denied { + return + } + if !h.remediationSvcReady(w) { + return + } + ctx := r.Context() + if _, err := h.remediationSvc.Get(ctx, uuid.UUID(rid)); mapRemediationErr(w, err) { + return + } + steps, err := h.remediationSvc.ListSteps(ctx, uuid.UUID(rid)) + if mapRemediationErr(w, err) { + return + } + resp := api.RemediationStepList{Steps: []api.RemediationStep{}} + for _, st := range steps { + resp.Steps = append(resp.Steps, toAPIStep(st)) + } + writeJSON(w, http.StatusOK, resp) +} + +// reviewRemediation is the shared body for approve/reject: parse rid + note, +// run the transition fn, map the result. +func (h *handlers) reviewRemediation( + w http.ResponseWriter, r *http.Request, rid openapitypes.UUID, + fn func(ctx context.Context, id, reviewer uuid.UUID, note string) (remediation.Request, error), +) { + if denied := auth.EnforcePermission(w, r, auth.RemediationApprove); denied { + return + } + if !h.remediationSvcReady(w) { + return + } + var req api.RemediationReview + _ = json.NewDecoder(r.Body).Decode(&req) // body optional + note := "" + if req.Note != nil { + note = *req.Note + } + reviewer, ok := h.reviewerID(w, r) + if !ok { + return + } + rq, err := fn(r.Context(), uuid.UUID(rid), reviewer, note) + if mapRemediationErr(w, err) { + return + } + writeJSON(w, http.StatusOK, toAPIRemediation(rq)) +} + +// ApproveRemediation implements api.ServerInterface. +// Spec api-remediation AC-02, AC-03, AC-05. +func (h *handlers) ApproveRemediation(w http.ResponseWriter, r *http.Request, rid openapitypes.UUID) { + h.reviewRemediation(w, r, rid, h.remediationSvc.Approve) +} + +// RejectRemediation implements api.ServerInterface. +// Spec api-remediation AC-02, AC-03, AC-05. +func (h *handlers) RejectRemediation(w http.ResponseWriter, r *http.Request, rid openapitypes.UUID) { + h.reviewRemediation(w, r, rid, h.remediationSvc.Reject) +} + +// licensedRemediationAct enforces the dangerous, license-gated permission +// (403 then 402 via the RBAC+license middleware) and, when the caller is +// entitled, reports 501: the host-mutating execution body is the OpenWatch+ +// licensed track, not built in the core. Spec api-remediation AC-06. +func (h *handlers) licensedRemediationAct(w http.ResponseWriter, r *http.Request, perm auth.Permission) { + if denied := auth.EnforcePermission(w, r, perm); denied { + return + } + writeError(w, http.StatusNotImplemented, "remediation.not_implemented", "server", + "remediation execution is an OpenWatch+ feature not yet implemented", false) +} + +// DryRunRemediation implements api.ServerInterface (OpenWatch+ licensed). +func (h *handlers) DryRunRemediation(w http.ResponseWriter, r *http.Request, _ openapitypes.UUID) { + h.licensedRemediationAct(w, r, auth.RemediationExecute) +} + +// ExecuteRemediation implements api.ServerInterface (OpenWatch+ licensed). +func (h *handlers) ExecuteRemediation(w http.ResponseWriter, r *http.Request, _ openapitypes.UUID) { + h.licensedRemediationAct(w, r, auth.RemediationExecute) +} + +// RollbackRemediation implements api.ServerInterface (OpenWatch+ licensed). +func (h *handlers) RollbackRemediation(w http.ResponseWriter, r *http.Request, _ openapitypes.UUID) { + h.licensedRemediationAct(w, r, auth.RemediationRollback) +} diff --git a/internal/server/server.go b/internal/server/server.go index 48c0f6fd..3e3d4e5d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -28,6 +28,7 @@ import ( "github.com/Hanalyx/openwatch/internal/kensa" "github.com/Hanalyx/openwatch/internal/license" "github.com/Hanalyx/openwatch/internal/notification" + "github.com/Hanalyx/openwatch/internal/remediation" "github.com/Hanalyx/openwatch/internal/report" "github.com/Hanalyx/openwatch/internal/scanresult" "github.com/Hanalyx/openwatch/internal/server/api" @@ -139,6 +140,14 @@ func (s *Server) WithExceptions(e *exception.Service) *Server { return s } +// WithRemediation threads the remediation governance service (free core) +// into the API handlers. Nil makes the remediation endpoints 503. +// Spec api-remediation. +func (s *Server) WithRemediation(rm *remediation.Service) *Server { + s.handlers.remediationSvc = rm + return s +} + // WithGroups threads the host group service (sites + OS categories) // into the API handlers so /api/v1/groups and its sub-routes are // routable. Nil makes the group endpoints 503. Spec api-groups. diff --git a/specs/api/remediation.spec.yaml b/specs/api/remediation.spec.yaml new file mode 100644 index 00000000..bf10deab --- /dev/null +++ b/specs/api/remediation.spec.yaml @@ -0,0 +1,125 @@ +spec: + id: api-remediation + title: Remediation governance - request/approve lifecycle + projected lift (OpenWatch Core, free) + version: "1.0.0" + status: approved + tier: 1 + + context: + system: openwatch-go + feature: > + The free (AGPLv3 core) half of Phase 7 remediation: a see-and-govern + loop over failing rules. Operators view what is remediable and the + projected compliance-score lift, request a fix, and route it through + approval. The act of mutating a host (dry-run / execute / rollback) is + the OpenWatch+ licensed track and is NOT in this spec - those endpoints + are gated by the remediation_execution license feature and return 402 + without it. + description: > + A remediation request is an operator's intent to fix a failing rule on a + host ("apply the fix for rule X on host Y"). It carries a request -> + approve | reject lifecycle, DB-backed, mirroring the exception governance + overlay (api-compliance-exceptions). The free service NEVER connects to a + host and NEVER mutates host_rule_state or transactions: Request, Approve, + Reject are pure state transitions over remediation_requests, and + ProjectLift is a read-only projection over host_rule_state. + + OPEN-CORE BOUNDARY (load-bearing): read (remediation:read), request + (remediation:request), and approve/reject (remediation:approve) are free. + dry-run/execute/rollback require BOTH the dangerous remediation:execute / + remediation:rollback permission AND the remediation_execution license + feature; without the license the gate returns 402 + license.feature_unavailable. The free build wires the gate; the execution + body is the licensed track (remediation_licensed_plan.md). + + Lifecycle: pending_approval -> approved | rejected. Separation of duties: + the requester cannot review their own request (enforced in the service, + on top of the distinct remediation:request vs remediation:approve RBAC + permissions). + + Projected lift: ProjectLift reads host_rule_state.framework_refs for a + failing rule and estimates the per-framework score delta if the rule + flips to pass (one passing rule is 1/N of that framework's rules on the + host). It is best-effort: when framework data is unavailable it returns an + empty projection and never fails the request. + + related_specs: + - api-compliance-exceptions + - api-host-compliance + - system-rbac + - system-license-enforcement + - system-audit-emission + + objective: + summary: > + A correct, auditable remediation request/approve lifecycle with + separation of duties, one open request per host+rule, a read-only + projected-lift estimate, and a license gate that keeps host mutation + behind OpenWatch+ - all without ever touching a host in the free path. + scope: + includes: + - remediation_requests + remediation_transactions tables (migration 0037) + - The free lifecycle service - Request / Approve / Reject / Get / ListRequests / ListSteps / ProjectLift + - The free HTTP endpoints (list, get, request, approve, reject, steps, audit-events) + - The license gate on the act endpoints (:dry-run, :execute, :rollback) returning 402 without remediation_execution + excludes: + - The act of mutating a host - dry-run / execute / rollback bodies (OpenWatch+ licensed track) + - The fleet auto-remediation policy engine (remediation_auto, licensed) + - Bulk / grouped remediation and Kensa rule ordering (blocked on a Kensa ratification) + - Score recomputation (ProjectLift is an estimate, it does not change the stored score) + + constraints: + - id: C-01 + description: 'The free remediation path NEVER mutates a host and NEVER writes host_rule_state or transactions: Request/Approve/Reject only write remediation_requests, and ProjectLift only reads host_rule_state. No free code path opens an SSH/Kensa transport (source inspection of internal/remediation)' + type: technical + enforcement: error + - id: C-02 + description: 'At most ONE open remediation request may exist per (host_id, rule_id), enforced by a partial-unique index over in-flight statuses (pending_approval, approved, dry_run_complete, executing); a duplicate Request returns ErrDuplicateOpen. Terminal rows (rejected, executed, rolled_back, failed) are historical and do not block a fresh request' + type: technical + enforcement: error + - id: C-03 + description: 'Lifecycle transitions are guarded: Approve and Reject require status=pending_approval; a transition from any other state returns ErrWrongState. The transition locks the row (FOR UPDATE) so concurrent reviewers cannot double-transition. Approve emits remediation.approved; Reject emits remediation.approved with detail.outcome=rejected (the registered taxonomy has no separate rejected code)' + type: technical + enforcement: error + - id: C-04 + description: 'Separation of duties: Approve and Reject reject a reviewer equal to the requester (ErrSelfReview), independent of RBAC' + type: security + enforcement: error + - id: C-05 + description: 'RBAC: request requires remediation:request, read endpoints require remediation:read, approve+reject require remediation:approve; anonymous callers are rejected on all. Request 404s hosts.not_found for an unknown host_id before any remediation write' + type: security + enforcement: error + - id: C-06 + description: 'License gate (open-core boundary): the act endpoints :dry-run, :execute, :rollback require the remediation_execution license feature; without it the gate returns 402 license.feature_unavailable. read/request/approve/reject carry no license requirement and succeed on the free tier. The gate is enforced via the RBAC+license middleware (remediation:execute / remediation:rollback are license_gated to remediation_execution)' + type: security + enforcement: error + - id: C-07 + description: 'ProjectLift is read-only and best-effort: for a failing rule it returns per-framework projected score deltas derived from host_rule_state.framework_refs (delta ~= 100/N where N is the count of that framework''s rules on the host); when framework data is absent or unparseable it returns an empty projection and never errors or blocks the request' + type: technical + enforcement: warning + + acceptance_criteria: + - id: AC-01 + description: 'Request inserts a pending_approval row (host_id, rule_id, requested_by, optional scan_run_id and projected-lift snapshot) and emits remediation.requested; a second Request for the same host+rule while one is open (pending_approval/approved/dry_run_complete/executing) returns ErrDuplicateOpen; an empty rule_id returns ErrInvalidInput; a fresh Request after the prior one reached a terminal state succeeds' + priority: critical + references_constraints: [C-02] + - id: AC-02 + description: 'Approve (requester != reviewer) moves pending_approval->approved, sets reviewed_by/reviewed_at, emits remediation.approved; Reject moves pending_approval->rejected and emits remediation.approved with detail.outcome=rejected. Approving or Rejecting a non-pending_approval row returns ErrWrongState' + priority: critical + references_constraints: [C-03] + - id: AC-03 + description: 'Approve and Reject with reviewer == requester return ErrSelfReview and leave the row unchanged' + priority: critical + references_constraints: [C-04] + - id: AC-04 + description: 'No free remediation path writes host_rule_state or transactions, and no free path opens a host transport (source inspection of internal/remediation). ProjectLift returns per-framework deltas for a failing rule read from host_rule_state, and degrades to an empty projection (no error) when framework_refs is absent' + priority: critical + references_constraints: [C-01, C-07] + - id: AC-05 + description: 'Endpoint RBAC + lifecycle: POST /api/v1/remediation/requests requires remediation:request and 404s an unknown host_id; GET /requests, GET /requests/{id}, GET /requests/{id}/steps require remediation:read; POST :approve and :reject require remediation:approve; anonymous callers are rejected on all. A full request->approve happy path round-trips through the HTTP layer' + priority: critical + references_constraints: [C-05, C-03] + - id: AC-06 + description: 'License gate: on the free tier (no remediation_execution feature) POST /requests/{id}:dry-run, :execute, :rollback return 402 license.feature_unavailable, while POST /requests (request), :approve, :reject and the GET reads return their normal status codes. A caller lacking remediation:execute is 403 before the license check (RBAC fails first)' + priority: critical + references_constraints: [C-06] diff --git a/specs/frontend/remediation-tab.spec.yaml b/specs/frontend/remediation-tab.spec.yaml new file mode 100644 index 00000000..19889f43 --- /dev/null +++ b/specs/frontend/remediation-tab.spec.yaml @@ -0,0 +1,79 @@ +spec: + id: frontend-remediation-tab + title: Host detail Remediation tab + per-rule request affordance (free tier) + version: "1.0.0" + status: approved + tier: 2 + + context: + system: openwatch-go + feature: The free-tier remediation governance frontend - a per-failing-rule Request-remediation affordance on the Compliance tab plus a read-only Remediation tab that drives the request -> approve | reject lifecycle and renders the host-mutating apply step as an OpenWatch+ upsell. + description: > + The frontend half of the free-tier remediation governance feature. + The backend exposes /api/v1/remediation/* (api-remediation): + + GET /api/v1/remediation/requests?host_id= - this host's requests + POST /api/v1/remediation/requests - file a request (201) + POST /api/v1/remediation/requests/{rid}:approve|:reject - review (200) + + The act verbs (:dry-run, :execute, :rollback) are OpenWatch+ + licensed and return 402 on the free tier; the UI never calls them. + Instead it renders the host-mutating affordance as a DISABLED + OpenWatch+ upsell. + + The free-tier lifecycle the UI drives is + pending_approval -> approved | rejected. useHostRemediations + mirrors useHostExceptions: it fetches the host's requests under + the ['host', hostId, 'remediations'] key (riding the + scan.completed SSE invalidation) and derives the open set used to + suppress a duplicate per-rule request. + + related_specs: + - api-remediation + - frontend-host-compliance-tab + - frontend-settings-exception-queue + + objective: + summary: > + A usable free-tier remediation surface: request a fix from a + failing rule, see this host's requests, approve or reject within + the caller's authority, and a clear OpenWatch+ upsell for the + host-mutating apply step that is never wired to the act endpoints. + scope: + includes: + - useHostRemediations hook (query key, endpoint, derived open/pending sets) + - Per-rule Request-remediation affordance on the Compliance tab, gated on remediation:request + - RemediationTab read panel on host detail with approve/reject gating and the atomic-model explainer + - The OpenWatch+ upsell for the host-mutating apply step (disabled, never calls the act endpoints) + excludes: + - Calling :dry-run / :execute / :rollback (OpenWatch+ licensed track) + - The transaction journal (steps) view (empty in the free build) + - A fleet-wide remediation queue (host-scoped only here) + + constraints: + - id: C-01 + description: 'useHostRemediations queries GET /api/v1/remediation/requests with host_id and key ["host", hostId, "remediations"], and derives openRuleIds (status in pending_approval/approved/dry_run_complete/executing) + pendingRuleIds (status pending_approval). Mutations invalidate ["host", hostId, "remediations"] on success' + type: technical + enforcement: error + - id: C-02 + description: 'The Compliance tab renders a per-failing-rule Request-remediation affordance gated on remediation:request; a rule already in the open set renders "Remediation requested" instead of the action. Submitting POSTs /api/v1/remediation/requests {host_id, rule_id}; a 409 surfaces inline as "already been requested". UI copy carries no em-dashes' + type: technical + enforcement: error + - id: C-03 + description: 'The Remediation tab lists this host''s requests (status chip, rule_id, projected lift) newest first. On a pending_approval row, a caller with remediation:approve gets Approve / Reject (POST :approve / :reject, invalidate on success, 409 inline); without it, "Awaiting approval". The host-mutating apply affordance renders as a DISABLED OpenWatch+ upsell and is never wired to the :dry-run / :execute / :rollback endpoints' + type: security + enforcement: error + + acceptance_criteria: + - id: AC-01 + description: 'Source inspection: useHostRemediations queries "/api/v1/remediation/requests" with queryKey ["host", hostId, "remediations"], passes host_id, and derives openRuleIds over pending_approval/approved/dry_run_complete/executing plus pendingRuleIds over pending_approval' + priority: critical + references_constraints: [C-01] + - id: AC-02 + description: 'Source inspection: the Compliance tab gates the affordance on hasPermission("remediation:request"), suppresses it for rules in remediationOpenRuleIds (rendering "Remediation requested"), and the request modal POSTs "/api/v1/remediation/requests" with {host_id, rule_id} and maps a 409 to an inline "already been requested" message. No em-dash characters appear in the affordance/modal copy' + priority: critical + references_constraints: [C-02] + - id: AC-03 + description: 'Source inspection: RemediationTab reads useHostRemediations, gates Approve/Reject on hasPermission("remediation:approve") for pending_approval rows (POSTing "/api/v1/remediation/requests/{rid}:approve" / ":reject" and invalidating ["host", hostId, "remediations"]), shows "Awaiting approval" otherwise, renders the Capture/Apply/Validate/Commit explainer, and renders a DISABLED OpenWatch+ upsell. The act endpoints (:execute, :rollback, :dry-run) are never referenced in the file' + priority: high + references_constraints: [C-03] diff --git a/specter.yaml b/specter.yaml index 3c02ec39..974a4aab 100644 --- a/specter.yaml +++ b/specter.yaml @@ -91,6 +91,8 @@ domains: - api-auth-policy - system-sso - api-sso + - api-remediation + - frontend-remediation-tab settings: specs_dir: specs # Honored by `specter coverage` (not by `specter sync` — CI passes