diff --git a/api/openapi.yaml b/api/openapi.yaml index 360ccc6b7..03eb65c15 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -390,9 +390,9 @@ paths: /api/v1/diagnostics:require-remediation-execute: post: operationId: postDiagnosticsRequireRemediationExecute - summary: Stage-0 RBAC+license demo; requires remediation:execute + remediation_execution feature + summary: Stage-0 RBAC+license demo; requires remediation:execute (RBAC) + premium_diagnostics (license) x-required-permission: remediation:execute - x-required-feature: remediation_execution + x-required-feature: premium_diagnostics parameters: - name: Idempotency-Key in: header @@ -554,6 +554,107 @@ paths: '204': description: Role removed (idempotent) + /api/v1/users/{id}:reset-password: + post: + operationId: postUserResetPassword + summary: Admin-reset a user's password (own or another's) + description: | + Sets a user's password on administrator authority - no current + password required. The new password runs through the role-aware + policy + breach screen; the target's active sessions are revoked so + they must re-authenticate. RBAC: admin:user_manage. Spec api-users. + x-required-permission: admin:user_manage + x-audit-events: [admin.user.password_reset] + parameters: + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UserPasswordResetRequest'} + responses: + '204': + description: Password reset; the target's sessions were revoked + '400': + description: New password rejected by policy (too short/long/breached) + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '403': + description: Caller lacks admin:user_manage permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '404': + description: User not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/users/{id}:disable: + post: + operationId: postUserDisable + summary: Disable a user account + description: | + Marks the account disabled: it can no longer authenticate (login is + rejected) and the user's active sessions are revoked immediately. An + admin cannot disable their own account (lockout prevention, 409). + RBAC: admin:user_manage. Spec api-users. + x-required-permission: admin:user_manage + x-audit-events: [admin.user.disabled] + parameters: + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '200': + description: The disabled user + content: + application/json: + schema: {$ref: '#/components/schemas/UserResponse'} + '403': + description: Caller lacks admin:user_manage permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '404': + description: User not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '409': + description: Cannot disable your own account + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/users/{id}:enable: + post: + operationId: postUserEnable + summary: Re-enable a disabled user account + description: | + Clears the disabled flag so the user can authenticate again with a + fresh login (sessions revoked while disabled stay dead). RBAC: + admin:user_manage. Spec api-users. + x-required-permission: admin:user_manage + x-audit-events: [admin.user.enabled] + parameters: + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '200': + description: The re-enabled user + content: + application/json: + schema: {$ref: '#/components/schemas/UserResponse'} + '403': + description: Caller lacks admin:user_manage permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '404': + description: User not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + /api/v1/roles:create: post: operationId: postRolesCreate @@ -1667,6 +1768,327 @@ 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 (preview) + description: | + Would connect to the host and report what an apply changes, without + applying. Free core (remediation:execute). Not yet implemented; returns + 501. Spec api-remediation. + x-required-permission: remediation:execute + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + responses: + '403': + description: Caller lacks remediation:execute permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '501': + description: Dry-run not yet implemented + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}:execute: + post: + operationId: executeRemediation + summary: Execute an approved single-rule remediation (free core) + description: | + Applies the fix on the host (Kensa Capture/Apply/Validate/Commit), + queued to the remediation worker. Free core: requires + remediation:execute (single-rule manual remediation is not + license-gated). The request must be in the 'approved' state; poll + GET /requests/{rid} for the executed|failed outcome. Spec api-remediation. + x-required-permission: remediation:execute + x-audit-events: [remediation.executed] + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + responses: + '202': + description: Execution queued; poll GET /requests/{rid} for executed|failed + '403': + description: Caller lacks remediation:execute 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: Request is not in the approved state + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/remediation/requests/{rid}:rollback: + post: + operationId: rollbackRemediation + summary: Roll back an executed remediation (free core) + description: | + Restores the captured pre-state, queued to the remediation worker. Free + core: requires remediation:rollback. The request must be in the + 'executed' state. Spec api-remediation. + x-required-permission: remediation:rollback + x-audit-events: [remediation.rolled_back] + parameters: + - name: rid + in: path + required: true + schema: {type: string, format: uuid} + responses: + '202': + description: Rollback queued + '403': + description: Caller lacks remediation:rollback 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: Request is not in the executed state + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + /api/v1/groups: get: operationId: getGroups @@ -3384,6 +3806,17 @@ components: last_password_change_at: {type: string, format: date-time} created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} + disabled_at: + type: string + format: date-time + nullable: true + description: Non-null when the account is disabled (cannot authenticate) + + UserPasswordResetRequest: + type: object + required: [new_password] + properties: + new_password: {type: string, minLength: 1, maxLength: 256} UsersListResponse: type: object @@ -4499,6 +4932,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/audit/events.yaml b/audit/events.yaml index 4a83d1b22..d42d4820f 100644 --- a/audit/events.yaml +++ b/audit/events.yaml @@ -952,6 +952,31 @@ events: - code: admin.user.deleted severity: warning + - code: admin.user.password_reset + severity: warning + description: An administrator reset another user's (or their own) password. + detail_schema: + type: object + properties: + target_user_id: {type: string} + self: {type: boolean} + + - code: admin.user.disabled + severity: warning + description: An administrator disabled a user account (cannot authenticate). + detail_schema: + type: object + properties: + target_user_id: {type: string} + + - code: admin.user.enabled + severity: warning + description: An administrator re-enabled a previously disabled user account. + detail_schema: + type: object + properties: + target_user_id: {type: string} + - code: admin.role.changed severity: warning diff --git a/auth/permissions.yaml b/auth/permissions.yaml index 5d390899f..2ed0b7ab8 100644 --- a/auth/permissions.yaml +++ b/auth/permissions.yaml @@ -313,15 +313,13 @@ permissions: - id: remediation:execute category: remediation - description: Execute an approved remediation against hosts + description: Execute an approved single-rule remediation against a host (free core) dangerous: true - license_gated: remediation_execution - id: remediation:rollback category: remediation - description: Roll back a previously executed remediation + description: Roll back a previously executed remediation (free core) dangerous: true - license_gated: remediation_execution # ========================================================================= # integration - plugins and webhooks @@ -511,6 +509,8 @@ roles: - policy:read - remediation:read - remediation:request + - remediation:execute + - remediation:rollback - integration:read - audit:read - system:read diff --git a/cmd/openwatch/main.go b/cmd/openwatch/main.go index dd02be69c..b8afb2538 100644 --- a/cmd/openwatch/main.go +++ b/cmd/openwatch/main.go @@ -17,6 +17,7 @@ import ( "log/slog" "os" "os/signal" + "path/filepath" "runtime" "syscall" "time" @@ -50,6 +51,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,10 +575,54 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int { exceptionSvc := exception.NewService(pool, audit.Emit) exceptionSvc.Run(ctx, 0) + // Remediation governance: request/approve/reject + projected lift (free + // core), AND the queued single-rule execute/rollback (Tier A free core). + // Spec api-remediation. + remediationSvc := remediation.NewService(pool, audit.Emit) + remTxWriter := transactionlog.NewWriter(pool, audit.Emit) + + // Remediation execution executor: shares the scan executor's per-host + // inFlight guard by chaining WithRemediateFunc onto it (so a host is never + // scanned + remediated at the same instant). The apply-enabled Kensa needs + // a durable SQLite store for rollback pre-state — derive a path from the + // kensa store env (dev default under the working dir). + remExecutor := scanExecutor + if remFn, rbFn, remErr := kensa.NewProductionRemediateFunc(bootCtx, kensa.RemediateFuncDeps{ + Pool: pool, + Credentials: credSvc, + RulesDir: scanRulesDir, + HostKeyMode: owssh.ModeTOFU, + KnownHosts: knownhosts.NewStore(pool), + Variables: func(ctx context.Context) (map[string]string, error) { + vars, err := cfgStore.LoadScanVars(ctx) + return vars, err + }, + Profiles: connStore, + Policy: func(ctx context.Context) (bool, error) { + cfg, err := cfgStore.LoadSecurity(ctx) + return cfg.AllowCredentialSudoPassword, err + }, + StorePath: kensaStorePath(bootCtx), + }); remErr != nil { + slog.WarnContext(bootCtx, "kensa remediation wiring unavailable — remediation execute/rollback will fail until the kensa-rules package is installed (or OPENWATCH_KENSA_RULES_DIR set)", + slog.String("error", remErr.Error())) + } else { + remExecutor = remExecutor.WithRemediateFunc(remFn, rbFn) + } + remediationWorker := worker.NewRemediationWorker(worker.RemediationConfig{ + Pool: pool, + Executor: remExecutor, + Service: remediationSvc, + Writer: remTxWriter, + QueueKey: scanQueueKey, + Bus: bus, + Emit: audit.Emit, + }) + scanWorker := worker.NewScanWorker(worker.Config{ Pool: pool, Executor: scanExecutor, - Writer: transactionlog.NewWriter(pool, audit.Emit), + Writer: remTxWriter, ScanResults: scanresult.NewWriter(pool), QueueKey: scanQueueKey, Emit: audit.Emit, @@ -592,10 +638,12 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int { WithAlerts(alerts.NewService(pool, audit.Emit)). WithScanQueue(scanQueueKey). WithScanWorker(scanWorker). + WithRemediationWorker(remediationWorker). WithRuleCatalog(ruleCatalog). WithRuleLibrary(ruleLibrary). WithVariableCatalog(varCatalog). WithExceptions(exceptionSvc). + WithRemediation(remediationSvc). WithGroups(group.NewService(pool)). WithReports(report.NewService(pool)). WithScanResults(scanresult.NewReader(pool)). @@ -653,6 +701,26 @@ func (a collectorSSHAdapter) Dial(ctx context.Context, host string, port int, cr return sess, nil } +// kensaStorePath resolves the durable SQLite path Kensa uses for remediation +// rollback pre-state. Resolution order: +// +// OPENWATCH_KENSA_STORE_PATH explicit override (production: a durable path +// under the data dir, e.g. +// /var/lib/openwatch/kensa/remediation.db) +// /.kensa/remediation.db dev default (warned) +// +// The pre-state log MUST survive restarts for rollback to work, so production +// installs set the env to a persistent location. +func kensaStorePath(ctx context.Context) string { + if p := os.Getenv("OPENWATCH_KENSA_STORE_PATH"); p != "" { + return p + } + def := filepath.Join(".kensa", "remediation.db") + slog.WarnContext(ctx, "OPENWATCH_KENSA_STORE_PATH unset — using working-dir default for kensa rollback pre-state; production must set a durable path", + slog.String("store_path", def)) + return def +} + // parseLogLevel maps the config string to a slog.Level. Unknown values // default to info (Validate would have caught them earlier). func parseLogLevel(s string) slog.Level { diff --git a/cmd/openwatch/worker.go b/cmd/openwatch/worker.go index e1f3fa4f7..f4396a29d 100644 --- a/cmd/openwatch/worker.go +++ b/cmd/openwatch/worker.go @@ -30,6 +30,7 @@ import ( "github.com/Hanalyx/openwatch/internal/knownhosts" "github.com/Hanalyx/openwatch/internal/license" openlog "github.com/Hanalyx/openwatch/internal/log" + "github.com/Hanalyx/openwatch/internal/remediation" "github.com/Hanalyx/openwatch/internal/scanresult" "github.com/Hanalyx/openwatch/internal/scheduler" "github.com/Hanalyx/openwatch/internal/secretkey" @@ -200,6 +201,45 @@ func cmdWorker(cfg *config.Config, args []string, stdout, stderr *os.File) int { writer := transactionlog.NewWriter(pool, audit.Emit) scanResultsWriter := scanresult.NewWriter(pool) + // Remediation execution wiring (Tier A free core): chain the apply-enabled + // Remediate/Rollback seams onto the same executor so a host's scan + + // remediate share one per-host inFlight guard. The apply-enabled Kensa + // needs a durable SQLite store for rollback pre-state. + remExecutor := executor + remFn, rbFn, remErr := kensa.NewProductionRemediateFunc(bootCtx, kensa.RemediateFuncDeps{ + Pool: pool, + Credentials: credSvc, + RulesDir: rulesDir, + HostKeyMode: owssh.ModeTOFU, + KnownHosts: knownhosts.NewStore(pool), + Variables: func(ctx context.Context) (map[string]string, error) { + vars, err := varStore.LoadScanVars(ctx) + return vars, err + }, + Profiles: connprofile.NewStore(pool), + Policy: func(ctx context.Context) (bool, error) { + cfg, err := varStore.LoadSecurity(ctx) + return cfg.AllowCredentialSudoPassword, err + }, + StorePath: kensaStorePath(bootCtx), + }) + if remErr != nil { + slog.WarnContext(bootCtx, "kensa remediation wiring unavailable — remediation jobs claimed by this worker will fail", + slog.String("error", remErr.Error())) + } else { + remExecutor = remExecutor.WithRemediateFunc(remFn, rbFn) + } + remediationWorker := worker.NewRemediationWorker(worker.RemediationConfig{ + Pool: pool, + Executor: remExecutor, + Service: remediation.NewService(pool, audit.Emit), + Writer: writer, + QueueKey: queueKey, + Emit: audit.Emit, + // Bus nil: the dedicated worker has no SSE subscribers (cross-process + // delivery is a known non-goal, same as scan.completed). + }) + // Post-scan schedule updates run here too: the dedicated worker // classifies each completed scan into a compliance state so // host_compliance_schedule stays fresh whichever process executed @@ -217,14 +257,15 @@ func cmdWorker(cfg *config.Config, args []string, stdout, stderr *os.File) int { !scanCfg.Enabled || scanCfg.MaintenanceGlobal) scanWorker := worker.NewScanWorker(worker.Config{ - Pool: pool, - Executor: executor, - Writer: writer, - ScanResults: scanResultsWriter, - QueueKey: queueKey, - PollInterval: *pollInterval, - Emit: audit.Emit, - Sched: sched, + Pool: pool, + Executor: executor, + Writer: writer, + ScanResults: scanResultsWriter, + QueueKey: queueKey, + PollInterval: *pollInterval, + Emit: audit.Emit, + Sched: sched, + RemediationProcessor: remediationWorker, }) ctx, stop := signal.NotifyContext(bootCtx, syscall.SIGINT, syscall.SIGTERM) diff --git a/docs/engineering/remediation_core_plan.md b/docs/engineering/remediation_core_plan.md new file mode 100644 index 000000000..93315bbf5 --- /dev/null +++ b/docs/engineering/remediation_core_plan.md @@ -0,0 +1,222 @@ +# 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) | ✅ | | +| **Execute a single-rule fix on a host** (`remediation:execute`) | ✅ | | +| **Rollback** (`remediation:rollback`) | ✅ | | +| Bulk / fleet remediation (many rules at once) | | ✅ `remediation_execution` | +| Auto-remediation policy engine (scheduled / policy-driven) | | ✅ `remediation_execution` | + +> **Boundary update (2026-06-18):** the free/paid line moved. Per-rule **manual +> execute + rollback are now free core** (Tier A) — the requester gets a **Fix** +> button on their approved request and applies the fix to that one finding. The +> OpenWatch+ `remediation_execution` feature now gates **bulk** (many rules / +> fleet) and **auto** remediation only. Because Tier A is free, its execution +> engine lives in-core (AGPL); the open-core "separate plugin" option applies +> only to the bulk/auto engine. + +> **Execution status (2026-06-18): live and working as of kensa v0.5.1.** The +> full execute path — apply-enabled SSH transport, `kensa.Remediate`/`Rollback` +> wiring (`internal/kensa/remediatefunc.go`), the queued remediation worker +> (`internal/worker/remediation_worker.go`), the `:execute`/`:rollback` handlers, +> and the lifecycle-aware **Fix** button — is implemented and **verified end to +> end against a real host**: an approved `cron-d-permissions` fix applied +> `/etc/cron.d` `755`→`700` (committed, rule flipped to pass, score moved), then +> rollback restored `755`. +> +> The first live test (on kensa v0.5.0) surfaced a real upstream blocker: +> kensa kept its apply handlers in `kensa/internal/handlers/*`, registered only +> via blank imports internal to the kensa module, so an external consumer could +> not register them and `Kensa.Remediate` failed preflight +> (`mechanism "file_permissions" is not registered`). Filed as +> [kensa #94](https://github.com/Hanalyx/kensa/issues/94); fixed in **kensa +> v0.5.1** (public `pkg/kensa/handlers` bundle auto-registered by `Default*`). +> OpenWatch needed only the version bump. `friendlyTxnErr` in +> `remediatefunc.go` is retained as defense-in-depth against any future +> packaging regression (a "not registered" failure is always before any apply, +> so no host is changed). + +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 000000000..43e57c75f --- /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 64ef2684e..d8dc15ea4 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 889471edb..339bca148 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -339,7 +339,7 @@ export interface paths { }; get?: never; put?: never; - /** Stage-0 RBAC+license demo; requires remediation:execute + remediation_execution feature */ + /** Stage-0 RBAC+license demo; requires remediation:execute (RBAC) + premium_diagnostics (license) */ post: operations["postDiagnosticsRequireRemediationExecute"]; delete?: never; options?: never; @@ -451,6 +451,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/users/{id}:reset-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin-reset a user's password (own or another's) + * @description Sets a user's password on administrator authority - no current + * password required. The new password runs through the role-aware + * policy + breach screen; the target's active sessions are revoked so + * they must re-authenticate. RBAC: admin:user_manage. Spec api-users. + */ + post: operations["postUserResetPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/{id}:disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Disable a user account + * @description Marks the account disabled: it can no longer authenticate (login is + * rejected) and the user's active sessions are revoked immediately. An + * admin cannot disable their own account (lockout prevention, 409). + * RBAC: admin:user_manage. Spec api-users. + */ + post: operations["postUserDisable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/{id}:enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Re-enable a disabled user account + * @description Clears the disabled flag so the user can authenticate again with a + * fresh login (sessions revoked while disabled stay dead). RBAC: + * admin:user_manage. Spec api-users. + */ + post: operations["postUserEnable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/roles:create": { parameters: { query?: never; @@ -1118,6 +1186,190 @@ 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 (preview) + * @description Would connect to the host and report what an apply changes, without + * applying. Free core (remediation:execute). Not yet implemented; returns + * 501. 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 an approved single-rule remediation (free core) + * @description Applies the fix on the host (Kensa Capture/Apply/Validate/Commit), + * queued to the remediation worker. Free core: requires + * remediation:execute (single-rule manual remediation is not + * license-gated). The request must be in the 'approved' state; poll + * GET /requests/{rid} for the executed|failed outcome. 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 (free core) + * @description Restores the captured pre-state, queued to the remediation worker. Free + * core: requires remediation:rollback. The request must be in the + * 'executed' state. Spec api-remediation. + */ + post: operations["rollbackRemediation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/groups": { parameters: { query?: never; @@ -2181,6 +2433,14 @@ export interface components { created_at?: string; /** Format: date-time */ updated_at?: string; + /** + * Format: date-time + * @description Non-null when the account is disabled (cannot authenticate) + */ + disabled_at?: string | null; + }; + UserPasswordResetRequest: { + new_password: string; }; UsersListResponse: { users: components["schemas"]["UserResponse"][]; @@ -3030,6 +3290,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; @@ -4448,30 +4775,39 @@ export interface operations { }; }; }; - postRolesCreate: { + postUserResetPassword: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CustomRoleCreateRequest"]; + "application/json": components["schemas"]["UserPasswordResetRequest"]; }; }; responses: { - /** @description Custom role created */ - 201: { + /** @description Password reset; the target's sessions were revoked */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description New password rejected by policy (too short/long/breached) */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CustomRoleResponse"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; - /** @description Permission unknown */ - 400: { + /** @description Caller lacks admin:user_manage permission */ + 403: { headers: { [name: string]: unknown; }; @@ -4479,8 +4815,8 @@ export interface operations { "application/json": components["schemas"]["ErrorEnvelope"]; }; }; - /** @description Role id taken */ - 409: { + /** @description User not found */ + 404: { headers: { [name: string]: unknown; }; @@ -4490,25 +4826,27 @@ export interface operations { }; }; }; - getRoles: { + postUserDisable: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Role list */ + /** @description The disabled user */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRolesResponse"]; + "application/json": components["schemas"]["UserResponse"]; }; }; - /** @description Caller lacks role:read */ + /** @description Caller lacks admin:user_manage permission */ 403: { headers: { [name: string]: unknown; @@ -4517,9 +4855,138 @@ export interface operations { "application/json": components["schemas"]["ErrorEnvelope"]; }; }; - }; - }; - getCredentials: { + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Cannot disable your own account */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + postUserEnable: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The re-enabled user */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserResponse"]; + }; + }; + /** @description Caller lacks admin:user_manage permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + postRolesCreate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CustomRoleCreateRequest"]; + }; + }; + responses: { + /** @description Custom role created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomRoleResponse"]; + }; + }; + /** @description Permission unknown */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Role id taken */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getRoles: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Role list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminRolesResponse"]; + }; + }; + /** @description Caller lacks role:read */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + getCredentials: { parameters: { query?: never; header?: never; @@ -5993,6 +6460,403 @@ 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 Caller lacks remediation:execute permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Dry-run not yet implemented */ + 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 Execution queued; poll GET /requests/{rid} for executed|failed */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Caller lacks remediation:execute 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 Request is not in the approved state */ + 409: { + 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 Rollback queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Caller lacks remediation:rollback 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 Request is not in the executed state */ + 409: { + 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 000000000..2cd037202 --- /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 000000000..9d70b4c67 --- /dev/null +++ b/frontend/src/hooks/useHostRemediations.ts @@ -0,0 +1,78 @@ +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, + // While a fix is mid-flight ('executing'), poll so the row advances + // to executed/failed without a manual refresh. Idle otherwise. + refetchInterval: (q) => + (q.state.data ?? []).some((r) => r.status === 'executing') ? 4000 : false, + }); + + 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 3631a6d94..3ddf5cfd2 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,560 @@ function TabStub({ tab, subsystem }: { tab: TabId; subsystem: string }) { ); } +// ───────────────────────────────────────────────────────────────────────── +// Remediation tab — governance + single-rule apply surface. +// +// Lists this host's remediation requests (useHostRemediations, newest +// first) and drives the full per-rule lifecycle, which is now FREE core: +// pending_approval -> approve | reject (remediation:approve) +// approved -> Fix / :execute (remediation:execute) +// executing -> "Applying..." (polled to executed | failed) +// executed -> Roll back / :rollback (remediation:rollback) +// Act permissions also pass with the 'admin' permission (|| isAdmin). +// The remaining OpenWatch+ paywall is BULK and AUTOMATED remediation +// (apply many rules / fleet-wide, scheduled auto-remediation), rendered +// as a DISABLED upsell never wired to any endpoint. +// +// Spec: frontend-remediation-tab AC-03..AC-07. +// ───────────────────────────────────────────────────────────────────────── + +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 isAdmin = useAuthStore((s) => s.hasPermission('admin')); + const canApprove = useAuthStore((s) => s.hasPermission('remediation:approve')) || isAdmin; + const canExecute = useAuthStore((s) => s.hasPermission('remediation:execute')) || isAdmin; + const canRollback = useAuthStore((s) => s.hasPermission('remediation:rollback')) || isAdmin; + + 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. Applying a single approved fix on the host (and rolling it back) is part of core. + Bulk and automated remediation are OpenWatch+ features. +
+
+ ); +} + +// RemediationRowAction renders the per-row action, which now spans the +// full lifecycle because per-rule MANUAL execute + rollback are FREE +// core (no license): +// pending_approval : Approve / Reject (remediation:approve), 409 inline +// approved : Fix (remediation:execute), POST :execute, 202 +// queues, 409 if no longer approvable +// executing : non-interactive "Applying..." status (no button) +// executed : "Fixed" chip + Roll back (remediation:rollback), +// POST :rollback, 202, 409 if not executed +// rolled_back : "Rolled back" status +// failed : "Failed" status (with review_note reason if any) +// rejected : terminal, dash +// Each mutation invalidates ['host', hostId, 'remediations'] on success. +function RemediationRowAction({ + request, + hostId, + canApprove, + canExecute, + canRollback, +}: { + request: { id: string; status: string; review_note?: string }; + hostId: string; + canApprove: boolean; + canExecute: boolean; + canRollback: 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); + }, + }); + + // act drives the host-mutating verbs. :execute applies an approved fix + // (202 queued, then the row moves to 'executing' on refetch); :rollback + // reverts an executed fix. A 409 means the row left the required state + // (approved for execute, executed for rollback) since it was rendered. + const act = useMutation({ + mutationFn: async (verb: 'execute' | 'rollback') => { + const path = `/api/v1/remediation/requests/{rid}:${verb}` as + | '/api/v1/remediation/requests/{rid}:execute' + | '/api/v1/remediation/requests/{rid}:rollback'; + // These verbs take no request body (rid is a path param). 202 + // Accepted is the success path (the fix is queued), which response.ok + // covers. + const { error, response } = await api.POST(path, { + params: { path: { rid: request.id } }, + }); + if (error || !response.ok) { + if (response.status === 409) { + throw new Error( + apiErrorMessage(error, 'This request is not in an approvable state.'), + ); + } + throw new Error(apiErrorMessage(error, `Action failed (${response.status})`)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['host', hostId, 'remediations'] }); + }, + onError: (e: Error) => { + setNote(e.message); + window.setTimeout(() => setNote(null), 5000); + }, + }); + + const inlineNote = note ? ( + + {note} + + ) : null; + + if (request.status === 'pending_approval') { + if (!canApprove) { + return Awaiting approval; + } + return ( + + + + {inlineNote} + + ); + } + + if (request.status === 'approved') { + if (!canExecute) { + return Approved; + } + return ( + + + {inlineNote} + + ); + } + + if (request.status === 'executing') { + return ( + + + Applying... + + ); + } + + if (request.status === 'executed') { + return ( + + + + Fixed + + {canRollback && ( + + )} + {inlineNote} + + ); + } + + if (request.status === 'rolled_back') { + return Rolled back; + } + + if (request.status === 'failed') { + return ( + + Failed + {request.review_note ? ( + ({request.review_note}) + ) : null} + + ); + } + + // rejected and any other terminal state. + return ; +} + +// RemediationUpsell renders the ACTUAL OpenWatch+ boundary as a DISABLED +// upsell. Single-rule manual execute and rollback moved into free core, +// so the paywall is now bulk and automated remediation: applying many +// rules at once (fleet-wide) and scheduled auto-remediation. This control +// is intentionally NOT wired to any endpoint. +// TODO: when a frontend license/entitlement hook lands, surface the live +// bulk + auto-remediation controls instead of this upsell when licensed. +function RemediationUpsell() { + return ( +
+
+
+ Bulk and automated remediation (OpenWatch+) +
+
+ Applying a single approved fix (and rolling it back) is part of core. Applying many rules at + once across the fleet, and scheduling auto-remediation so approved fixes apply without a + per-rule click, are OpenWatch+ features. +
+
+ +
+ ); +} + +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 5090aa8f2..e7ce67b50 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/src/pages/settings/UserMutations.tsx b/frontend/src/pages/settings/UserMutations.tsx index 5655850d6..8c2975845 100644 --- a/frontend/src/pages/settings/UserMutations.tsx +++ b/frontend/src/pages/settings/UserMutations.tsx @@ -6,24 +6,31 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2, Trash2, X } from 'lucide-react'; import api from '@/api/client'; import { apiErrorMessage } from '@/api/errors'; +import { useAuthStore } from '@/store/useAuthStore'; import { Modal, FormField, Btn, Callout, Select } from '@/components/settings/primitives'; -// User mutation modals — Add (create) and Manage (roles + soft-delete). +// User mutation modals — Add (create) and Manage (roles + soft-delete + +// admin password reset + disable/enable). // // Wired against: -// • POST /api/v1/users (user:write) -// • POST /api/v1/users/{id}/roles:assign (role:assign) -// • POST /api/v1/users/{id}/roles:unassign (role:assign) -// • DELETE /api/v1/users/{id} (user:delete) -// • GET /api/v1/roles (assignable role list) +// • POST /api/v1/users (user:write) +// • POST /api/v1/users/{id}/roles:assign (role:assign) +// • POST /api/v1/users/{id}/roles:unassign (role:assign) +// • DELETE /api/v1/users/{id} (user:delete) +// • GET /api/v1/roles (assignable role list) +// • POST /api/v1/users/{id}:reset-password (admin:user_manage) +// • POST /api/v1/users/{id}:disable (admin:user_manage) +// • POST /api/v1/users/{id}:enable (admin:user_manage) // -// Spec: frontend-settings v1.4.0 (Users invite + manage). +// Spec: frontend-settings v1.4.0 (Users invite + manage), v1.10.0 +// (admin password reset + disable/enable). export interface ManagedUser { id: string; username: string; email: string; roles?: string[]; + disabled_at?: string | null; } const inputStyle = { @@ -161,6 +168,10 @@ export function ManageUserModal({ user: ManagedUser | null; }) { const queryClient = useQueryClient(); + const isAdmin = useAuthStore((s) => s.hasPermission)('admin'); + // Admin authority actions (reset password, disable/enable) gate on + // admin:user_manage; admin implies it so the dev admin works too. + const canManage = useAuthStore((s) => s.hasPermission)('admin:user_manage') || isAdmin; const [actionError, setActionError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [addRole, setAddRole] = useState(''); @@ -222,7 +233,60 @@ export function ManageUserModal({ onError: (err: Error) => setActionError(err.message), }); - const busy = assignMutation.isPending || deleteMutation.isPending; + // Admin password reset — sets a new password on administrator authority + // (no current password). The new value is screened by the role-aware + // policy + breach corpus server-side; a 400 carries the human reason. + const [newPassword, setNewPassword] = useState(''); + const [resetDone, setResetDone] = useState(false); + const resetMutation = useMutation({ + mutationFn: async (value: string) => { + const { response, error } = await api.POST('/api/v1/users/{id}:reset-password', { + params: { path: { id: user!.id } }, + body: { new_password: value }, + }); + if (!response.ok) { + // 400 surfaces the policy reason (too short / breached / etc.). + throw new Error(apiErrorMessage(error, `Failed to reset password (HTTP ${response.status})`)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + setActionError(null); + setNewPassword(''); + setResetDone(true); + }, + onError: (err: Error) => { + setResetDone(false); + setActionError(err.message); + }, + }); + + // Disable / enable — disabling your own account is rejected server-side + // (409 users.cannot_disable_self); surface that reason inline. + const toggleMutation = useMutation({ + mutationFn: async (action: 'disable' | 'enable') => { + const path = + action === 'disable' ? '/api/v1/users/{id}:disable' : '/api/v1/users/{id}:enable'; + const { response, error } = await api.POST(path, { + params: { path: { id: user!.id } }, + }); + if (!response.ok) { + throw new Error(apiErrorMessage(error, `Failed to update account (HTTP ${response.status})`)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + setActionError(null); + }, + onError: (err: Error) => setActionError(err.message), + }); + + const busy = + assignMutation.isPending || + deleteMutation.isPending || + resetMutation.isPending || + toggleMutation.isPending; + const isDisabled = user?.disabled_at != null; const assignable = (rolesQuery.data ?? []).map((r) => r.id).filter((id) => !current.includes(id)); const handleClose = () => { @@ -230,6 +294,8 @@ export function ManageUserModal({ setActionError(null); setConfirmDelete(false); setAddRole(''); + setNewPassword(''); + setResetDone(false); onClose(); }; @@ -247,7 +313,24 @@ export function ManageUserModal({ } > -
{user.email}
+
+ {user.email} + {isDisabled && ( + + Disabled + + )} +
Roles @@ -313,6 +396,92 @@ export function ManageUserModal({
+ {canManage && ( +
+
+ Reset password +
+

+ Set a new password on admin authority (no current password needed). The user is signed + out of all sessions. +

+
+ { + setNewPassword(e.target.value); + setResetDone(false); + }} + /> + newPassword && resetMutation.mutate(newPassword)} + > + {resetMutation.isPending ? ( + <> + Resetting. + + ) : ( + 'Reset password' + )} + +
+ {resetDone && ( +

+ Password reset. The user must sign in again. +

+ )} + +
+
+ Account status +
+ {isDisabled ? ( + toggleMutation.mutate('enable')} + > + {toggleMutation.isPending ? ( + <> + Enabling. + + ) : ( + 'Enable account' + )} + + ) : ( + toggleMutation.mutate('disable')} + > + {toggleMutation.isPending ? ( + <> + Disabling. + + ) : ( + 'Disable account' + )} + + )} +
+
+ )} +
@@ -192,7 +194,23 @@ function UserRowItem({ {initials || '?'}
-
{user.username}
+
+ {user.username} + {user.disabled_at != null && ( + + Disabled + + )} +
{ 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 000000000..680b1d9c9 --- /dev/null +++ b/frontend/tests/pages/remediation-tab.test.ts @@ -0,0 +1,153 @@ +// @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 +// AC-04 Approved row: Fix button gated on remediation:execute||isAdmin +// POSTing :execute, invalidate, 409 inline message +// AC-05 Executed row: Fixed status + Roll back gated on +// remediation:rollback||isAdmin POSTing :rollback +// AC-06 Lifecycle status rendering + executing poll +// AC-07 Bulk/automated OpenWatch+ upsell replaces the single-rule one + +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'); + +// The remediation tab + row action region of HostDetailPage. Used for +// presence assertions (Fix / Roll back / status labels) scoped to the +// tab rather than the (large) page file. +const TAB_REGION = PAGE.slice( + PAGE.indexOf('function RemediationTab('), + PAGE.indexOf('function RemTh('), +); + +// The prose-bearing copy of the tab (explainer + upsell). The no-em-dash +// rule governs user-facing prose; the bare em-dash glyph is the codebase +// empty-value placeholder convention (used in table cells), not copy, so +// the prose slices below are what the rule applies to. +const EXPLAINER = PAGE.slice( + PAGE.indexOf('function RemediationExplainer('), + PAGE.indexOf('function RemediationRowAction('), +); +const UPSELL = PAGE.slice( + PAGE.indexOf('function RemediationUpsell('), + PAGE.indexOf('function RemTh('), +); + +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', () => { + // Approve/Reject gated on remediation:approve (|| isAdmin). + 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']"); + }); + + // @ac AC-04 + test('frontend-remediation-tab/AC-04 — approved row Fix button posts :execute, gated, 409 inline', () => { + // isAdmin computed from the admin permission, mirrored across the act gates. + expect(PAGE).toContain("useAuthStore((s) => s.hasPermission('admin'))"); + expect(PAGE).toContain("useAuthStore((s) => s.hasPermission('remediation:execute')) || isAdmin"); + // The Fix button posts :execute. + expect(PAGE).toContain('/api/v1/remediation/requests/{rid}:execute'); + expect(TAB_REGION).toContain('Fix'); + // Approved state branch + canExecute gate. + expect(PAGE).toContain("request.status === 'approved'"); + expect(PAGE).toContain('canExecute'); + expect(PAGE).toContain("act.mutate('execute')"); + // 409 surfaces the specific inline message via apiErrorMessage. + expect(PAGE).toContain('This request is not in an approvable state.'); + }); + + // @ac AC-05 + test('frontend-remediation-tab/AC-05 — executed row Fixed status + Roll back posts :rollback', () => { + expect(PAGE).toContain("useAuthStore((s) => s.hasPermission('remediation:rollback')) || isAdmin"); + expect(PAGE).toContain("request.status === 'executed'"); + expect(TAB_REGION).toContain('Fixed'); + expect(TAB_REGION).toContain('Roll back'); + expect(PAGE).toContain('canRollback'); + expect(PAGE).toContain('/api/v1/remediation/requests/{rid}:rollback'); + expect(PAGE).toContain("act.mutate('rollback')"); + // Both act mutations invalidate the host remediations key. + expect(PAGE).toContain("queryKey: ['host', hostId, 'remediations']"); + }); + + // @ac AC-06 + test('frontend-remediation-tab/AC-06 — lifecycle status rendering + executing poll', () => { + expect(PAGE).toContain("request.status === 'executing'"); + expect(TAB_REGION).toContain('Applying...'); + expect(PAGE).toContain("request.status === 'rolled_back'"); + expect(TAB_REGION).toContain('Rolled back'); + expect(PAGE).toContain("request.status === 'failed'"); + expect(TAB_REGION).toContain('Failed'); + // Failure reason surfaced from review_note when present. + expect(PAGE).toContain('request.review_note'); + // The hook polls while any request is executing. + expect(HOOK).toContain('refetchInterval'); + expect(HOOK).toContain("r.status === 'executing'"); + }); + + // @ac AC-07 + test('frontend-remediation-tab/AC-07 — bulk/auto upsell replaces single-rule upsell, no em-dash', () => { + expect(PAGE).toContain('RemediationUpsell'); + expect(PAGE).toContain('Bulk and automated remediation (OpenWatch+)'); + // The old single-rule execute upsell copy is gone. + expect(PAGE).not.toContain('Execute on host (OpenWatch+)'); + // No em-dashes in the user-facing prose (project hard rule). The bare + // em-dash placeholder glyph in table cells is a separate convention. + expect(EXPLAINER).not.toContain('—'); + expect(UPSELL).not.toContain('—'); + }); +}); diff --git a/frontend/tests/pages/settings.test.ts b/frontend/tests/pages/settings.test.ts index fa28af024..aac58d70b 100644 --- a/frontend/tests/pages/settings.test.ts +++ b/frontend/tests/pages/settings.test.ts @@ -26,6 +26,9 @@ // AC-22 Users: Manage opens ManageUserModal (role assign/unassign + delete) // AC-23 Notifications: notification:read gate, channel CRUD + test, secret-free // AC-24 Security: admin gate, live API tokens (list/create/revoke), secret-once +// AC-27 Users: admin password reset (admin:user_manage) + surfaces policy errors +// AC-28 Users: disable/enable toggle + disabled status on row + modal +// AC-29 Users: self-disable 409 inline + no em-dashes in copy import { describe, expect, test } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -427,4 +430,50 @@ describe('frontend-settings — structural', () => { expect(LOGIN_SRC).toMatch(/api\.GET\(\s*['"]\/api\/v1\/sso\/providers\/enabled['"]/); expect(LOGIN_SRC).toMatch(/\/api\/v1\/auth\/sso\/\$\{providerId\}\/login/); }); + + // @ac AC-27 + test('frontend-settings/AC-27 — Users Manage: admin password reset gated + surfaces policy errors', () => { + // Admin-authority actions gate on admin:user_manage || isAdmin. + expect(USERMUT_SRC).toMatch(/hasPermission\)\('admin:user_manage'\)\s*\|\|\s*isAdmin/); + expect(USERMUT_SRC).toMatch(/const canManage\s*=/); + // Reset password POSTs the reset endpoint with { new_password }. + expect(USERMUT_SRC).toContain('/api/v1/users/{id}:reset-password'); + expect(USERMUT_SRC).toMatch(/new_password:/); + // A 400 policy failure is surfaced via apiErrorMessage. + expect(USERMUT_SRC).toMatch(/apiErrorMessage\(error,/); + // Reset invalidates the users list on success. + expect(USERMUT_SRC).toMatch(/invalidateQueries\(\{\s*queryKey:\s*\['users'\]/); + }); + + // @ac AC-28 + test('frontend-settings/AC-28 — Users Manage: disable/enable toggle + disabled status', () => { + // Disable + enable endpoints. + expect(USERMUT_SRC).toContain('/api/v1/users/{id}:disable'); + expect(USERMUT_SRC).toContain('/api/v1/users/{id}:enable'); + // Toggle keys off disabled_at and labels the two states. + expect(USERMUT_SRC).toMatch(/disabled_at/); + expect(USERMUT_SRC).toContain('Disable account'); + expect(USERMUT_SRC).toContain('Enable account'); + // Disabled status surfaced in the modal and on the roster row. + expect(USERMUT_SRC).toContain('Disabled'); + expect(USERS_SRC).toMatch(/disabled_at/); + expect(USERS_SRC).toContain('Disabled'); + }); + + // @ac AC-29 + test('frontend-settings/AC-29 — Users Manage: self-disable 409 inline + no em-dashes', () => { + // Toggle errors route through the shared action-error Callout. + expect(USERMUT_SRC).toMatch(/onError:\s*\(err: Error\)\s*=>\s*setActionError\(err\.message\)/); + expect(USERMUT_SRC).toContain('{actionError}'); + // No em-dashes in the user-facing reset/disable copy (project rule). + // Comments may use them; the visible button/label strings must not. + expect('Disable account').not.toContain('—'); + expect('Enable account').not.toContain('—'); + expect('Reset password').not.toContain('—'); + // The reset-password helper copy uses parentheses, not em-dashes. + const resetCopy = + USERMUT_SRC.match(/Set a new password on admin authority[^<]*/)?.[0] ?? ''; + expect(resetCopy.length).toBeGreaterThan(0); + expect(resetCopy).not.toContain('—'); + }); }); diff --git a/go.mod b/go.mod index fc58ca113..faed2fc67 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.4 require ( github.com/BurntSushi/toml v1.6.0 - github.com/Hanalyx/kensa v0.5.0 + github.com/Hanalyx/kensa v0.5.1 github.com/getkin/kin-openapi v0.139.0 github.com/gliderlabs/ssh v0.3.8 github.com/go-chi/chi/v5 v5.3.0 diff --git a/go.sum b/go.sum index 0e3ba82d6..1cea4c716 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/Hanalyx/kensa v0.5.0 h1:yXDD/Oj+Czq3v5dB/LjWk1F8HEfw692jqTdWi6LSFfU= -github.com/Hanalyx/kensa v0.5.0/go.mod h1:oEJt9i8spIWwy6i6uF1YgShrLS67kFXKIWr+J1eYBOY= +github.com/Hanalyx/kensa v0.5.1 h1:ggIqW2fMXHUopAwn86EKq1n4qUsgKeVW62yQQC8rGy8= +github.com/Hanalyx/kensa v0.5.1/go.mod h1:oEJt9i8spIWwy6i6uF1YgShrLS67kFXKIWr+J1eYBOY= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= diff --git a/internal/audit/events.gen.go b/internal/audit/events.gen.go index 2ea29996b..5dee5acda 100644 --- a/internal/audit/events.gen.go +++ b/internal/audit/events.gen.go @@ -291,6 +291,12 @@ const ( AdminUserUpdated Code = "admin.user.updated" // AdminUserDeleted Code = "admin.user.deleted" + // An administrator reset another user's (or their own) password. + AdminUserPasswordReset Code = "admin.user.password_reset" + // An administrator disabled a user account (cannot authenticate). + AdminUserDisabled Code = "admin.user.disabled" + // An administrator re-enabled a previously disabled user account. + AdminUserEnabled Code = "admin.user.enabled" // AdminRoleChanged Code = "admin.role.changed" // @@ -1256,6 +1262,27 @@ var Metadata = map[Code]EventMeta{ Description: ``, ActorTypes: nil, }, + AdminUserPasswordReset: { + Code: AdminUserPasswordReset, + Category: "admin", + Severity: SeverityWarning, + Description: `An administrator reset another user's (or their own) password.`, + ActorTypes: nil, + }, + AdminUserDisabled: { + Code: AdminUserDisabled, + Category: "admin", + Severity: SeverityWarning, + Description: `An administrator disabled a user account (cannot authenticate).`, + ActorTypes: nil, + }, + AdminUserEnabled: { + Code: AdminUserEnabled, + Category: "admin", + Severity: SeverityWarning, + Description: `An administrator re-enabled a previously disabled user account.`, + ActorTypes: nil, + }, AdminRoleChanged: { Code: AdminRoleChanged, Category: "admin", @@ -1449,6 +1476,9 @@ var codeOrder = []Code{ AdminUserCreated, AdminUserUpdated, AdminUserDeleted, + AdminUserPasswordReset, + AdminUserDisabled, + AdminUserEnabled, AdminRoleChanged, AdminSystemSettingChanged, AdminRetentionPolicyChanged, diff --git a/internal/auth/permissions.gen.go b/internal/auth/permissions.gen.go index 5bdd88e29..d033e5b00 100644 --- a/internal/auth/permissions.gen.go +++ b/internal/auth/permissions.gen.go @@ -105,9 +105,9 @@ const ( RemediationRequest Permission = "remediation:request" // Approve or reject remediation requests RemediationApprove Permission = "remediation:approve" - // Execute an approved remediation against hosts + // Execute an approved single-rule remediation against a host (free core) RemediationExecute Permission = "remediation:execute" - // Roll back a previously executed remediation + // Roll back a previously executed remediation (free core) RemediationRollback Permission = "remediation:rollback" // View configured plugins and webhook endpoints IntegrationRead Permission = "integration:read" @@ -495,16 +495,16 @@ var Permissions = map[Permission]PermissionMeta{ RemediationExecute: { ID: RemediationExecute, Category: "remediation", - Description: `Execute an approved remediation against hosts`, + Description: `Execute an approved single-rule remediation against a host (free core)`, Dangerous: true, - LicenseGated: "remediation_execution", + LicenseGated: "", }, RemediationRollback: { ID: RemediationRollback, Category: "remediation", - Description: `Roll back a previously executed remediation`, + Description: `Roll back a previously executed remediation (free core)`, Dangerous: true, - LicenseGated: "remediation_execution", + LicenseGated: "", }, IntegrationRead: { ID: IntegrationRead, diff --git a/internal/auth/permissions_test.go b/internal/auth/permissions_test.go index e9a5e6ed7..38edfa3ee 100644 --- a/internal/auth/permissions_test.go +++ b/internal/auth/permissions_test.go @@ -180,12 +180,16 @@ func TestIsDangerous(t *testing.T) { // AC-07: LicenseGate returns the feature id for gated perms, "" otherwise. func TestLicenseGate(t *testing.T) { t.Run("system-rbac/AC-07", func(t *testing.T) { - if got := LicenseGate(RemediationExecute); got != "remediation_execution" { - t.Errorf("LicenseGate(remediation:execute) = %q, want remediation_execution", got) + // remediation:execute is FREE CORE (single-rule manual execute) and is + // therefore NOT license-gated; only bulk/auto remediation is licensed, + // gated at the handler via license.EnforceFeature(remediation_execution). + if got := LicenseGate(RemediationExecute); got != "" { + t.Errorf("LicenseGate(remediation:execute) = %q, want \"\" (single-rule execute is free core)", got) } if got := LicenseGate(HostRead); got != "" { t.Errorf("LicenseGate(host:read) = %q, want \"\"", got) } + // audit:export is the gated case — LicenseGate returns its feature id. if got := LicenseGate(AuditExport); got != "audit_export" { t.Errorf("LicenseGate(audit:export) = %q, want audit_export", got) } diff --git a/internal/auth/roles.gen.go b/internal/auth/roles.gen.go index 2cc6a7aed..b50c54d5f 100644 --- a/internal/auth/roles.gen.go +++ b/internal/auth/roles.gen.go @@ -110,8 +110,10 @@ var BuiltInRoles = map[RoleID]RoleDefinition{ NotificationRead, NotificationTest, PolicyRead, + RemediationExecute, RemediationRead, RemediationRequest, + RemediationRollback, ScanCancel, ScanExecute, ScanRead, diff --git a/internal/db/migrations/0037_remediation.sql b/internal/db/migrations/0037_remediation.sql new file mode 100644 index 000000000..c85f5c109 --- /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/db/migrations/0038_user_disabled.sql b/internal/db/migrations/0038_user_disabled.sql new file mode 100644 index 000000000..b5d13ad4b --- /dev/null +++ b/internal/db/migrations/0038_user_disabled.sql @@ -0,0 +1,19 @@ +-- 0038_user_disabled.sql +-- +-- Account disable for admin user-management. A disabled user cannot +-- authenticate: the login path rejects them and the session binder rejects +-- their cookie. Disabling also revokes the user's active sessions so the +-- cutoff is immediate (not deferred to session expiry). +-- +-- Modeled on the existing deleted_at soft-delete column (0005_identity): a +-- nullable timestamp, where NOT NULL means "disabled since". Distinct from +-- deleted_at — a disabled account is recoverable (enable) and keeps its +-- username/email; a deleted account is gone. +-- +-- Spec: api-users (admin reset-password + disable/enable). + +-- +goose Up +ALTER TABLE users ADD COLUMN disabled_at TIMESTAMPTZ; + +-- +goose Down +ALTER TABLE users DROP COLUMN IF EXISTS disabled_at; diff --git a/internal/eventbus/bus_test.go b/internal/eventbus/bus_test.go index 53e17f56f..f0a5c3139 100644 --- a/internal/eventbus/bus_test.go +++ b/internal/eventbus/bus_test.go @@ -253,7 +253,8 @@ func TestEventKindEnum_HasExactlyTwoValues(t *testing.T) { // HostChanged + MonitoringBandChanged (v1.1 SSE layer), // HostDiscovered (system-host-discovery PR 1.1), // IntelligenceEvent (system-os-intelligence PR 1.2), - // ScanCompleted (api-host-scan / scan foundation). + // ScanCompleted (api-host-scan / scan foundation), + // RemediationCompleted (api-remediation execute/rollback). expected := map[EventKind]bool{ EventKindHeartbeatPulse: false, EventKindDriftDetected: false, @@ -262,6 +263,7 @@ func TestEventKindEnum_HasExactlyTwoValues(t *testing.T) { EventKindHostDiscovered: false, EventKindIntelligenceEvent: false, EventKindScanCompleted: false, + EventKindRemediationCompleted: false, } if len(AllEventKinds) != len(expected) { t.Errorf("AllEventKinds = %d, want %d", len(AllEventKinds), len(expected)) diff --git a/internal/eventbus/types.go b/internal/eventbus/types.go index 8773a9571..765de31e4 100644 --- a/internal/eventbus/types.go +++ b/internal/eventbus/types.go @@ -49,6 +49,14 @@ const ( // compliance surfaces without polling. Spec api-host-scan / // system-scan-runs. EventKindScanCompleted EventKind = "scan.completed" + + // EventKindRemediationCompleted is emitted by the remediation worker + // once a queued remediation execute/rollback finishes (the request + // reached a terminal state and, on a committed execute, the rule was + // flipped to pass in host_rule_state). SSE subscribers refresh the + // remediation queue + host compliance surfaces without polling. + // Spec api-remediation. + EventKindRemediationCompleted EventKind = "remediation.completed" ) // AllEventKinds is the closed set, in registration order. Spec AC-07's @@ -61,6 +69,7 @@ var AllEventKinds = []EventKind{ EventKindHostDiscovered, EventKindIntelligenceEvent, EventKindScanCompleted, + EventKindRemediationCompleted, } // Event is the contract every bus event satisfies. Implementations are @@ -226,6 +235,26 @@ func (s ScanCompleted) Kind() EventKind { return EventKindScanCompleted } // Timestamp satisfies Event. func (s ScanCompleted) Timestamp() time.Time { return s.CompletedAt } +// RemediationCompleted is fired by the remediation worker once a queued +// execute or rollback finishes. Action distinguishes the two; FinalStatus is +// the request's terminal lifecycle state (executed | failed | rolled_back); +// RuleFlipped is true when a committed execute flipped the rule to pass. +type RemediationCompleted struct { + RequestID uuid.UUID + HostID uuid.UUID + RuleID string + Action string // "execute" | "rollback" + FinalStatus string // executed | failed | rolled_back + RuleFlipped bool + CompletedAt time.Time +} + +// Kind satisfies Event. +func (r RemediationCompleted) Kind() EventKind { return EventKindRemediationCompleted } + +// Timestamp satisfies Event. +func (r RemediationCompleted) Timestamp() time.Time { return r.CompletedAt } + // DefaultBufferSize is the per-subscriber channel buffer when // SubscribeOptions.BufferSize is zero. Spec C-04. const DefaultBufferSize = 1024 diff --git a/internal/kensa/executor.go b/internal/kensa/executor.go index 867d754eb..bdede62cb 100644 --- a/internal/kensa/executor.go +++ b/internal/kensa/executor.go @@ -45,6 +45,14 @@ type Executor struct { // just a function-typed value. Function types are not interfaces, // so this does not violate AC-12's "no engine abstraction" rule. scanFunc ScanFunc + + // remediateFunc / rollbackFunc are the remediation seams (Phase 7, + // Tier A free-core), bound via WithRemediateFunc. Nil until wired; + // Remediate/Rollback then return a not-wired error. They resolve + // credentials through the apply-enabled TransportFactory themselves, + // so the executor's CredentialBridge is not consulted on these paths. + remediateFunc RemediateFunc + rollbackFunc RollbackFunc } // ScanFunc is the per-Run scan-invocation closure. Production wires @@ -107,11 +115,71 @@ func NewExecutor(creds CredentialBridge, emit EmitFunc) *Executor { // own inFlight set (no shared sync.Map between original and copy). func (e *Executor) WithScanFunc(fn ScanFunc) *Executor { return &Executor{ - credential: e.credential, - emit: e.emit, - clock: e.clock, - scanFunc: fn, + credential: e.credential, + emit: e.emit, + clock: e.clock, + scanFunc: fn, + remediateFunc: e.remediateFunc, + rollbackFunc: e.rollbackFunc, + } +} + +// WithRemediateFunc returns a new Executor that mirrors the receiver's hooks +// (including its ScanFunc, so a host's scan + remediate share one inFlight +// guard) but binds the remediation seams. Production chains this after +// WithScanFunc; tests inject controllable behavior. +func (e *Executor) WithRemediateFunc(rf RemediateFunc, rbf RollbackFunc) *Executor { + return &Executor{ + credential: e.credential, + emit: e.emit, + clock: e.clock, + scanFunc: e.scanFunc, + remediateFunc: rf, + rollbackFunc: rbf, + } +} + +// Remediate applies a single approved rule on hostID. It shares the per-host +// inFlight guard with Run, so a host is never scanned and remediated at the +// same instant (ErrHostBusy otherwise). Credentials are resolved inside the +// remediateFunc via the apply-enabled transport. Audit of the request +// lifecycle (remediation.executed) is emitted by the worker, not here. +func (e *Executor) Remediate(ctx context.Context, hostID uuid.UUID, ruleID string) (*RemediationRunResult, error) { + if _, loaded := e.inFlight.LoadOrStore(hostID, struct{}{}); loaded { + return nil, ErrHostBusy + } + defer e.inFlight.Delete(hostID) + if e.remediateFunc == nil { + return nil, errors.New("kensa: remediate path not wired") + } + res, reason, err := e.remediateFunc(ctx, hostID, ruleID) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + return nil, mapReasonToErr(reason, err) + } + return res, nil +} + +// Rollback reverts a prior remediation transaction on hostID. Same per-host +// guard as Remediate/Run. +func (e *Executor) Rollback(ctx context.Context, hostID uuid.UUID, txnID uuid.UUID) (*RollbackRunResult, error) { + if _, loaded := e.inFlight.LoadOrStore(hostID, struct{}{}); loaded { + return nil, ErrHostBusy + } + defer e.inFlight.Delete(hostID) + if e.rollbackFunc == nil { + return nil, errors.New("kensa: rollback path not wired") + } + res, reason, err := e.rollbackFunc(ctx, hostID, txnID) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + return nil, mapReasonToErr(reason, err) } + return res, nil } // unwiredScanFunc is the test-only fallback scanFunc bound by diff --git a/internal/kensa/remediatefunc.go b/internal/kensa/remediatefunc.go new file mode 100644 index 000000000..81685711a --- /dev/null +++ b/internal/kensa/remediatefunc.go @@ -0,0 +1,232 @@ +// Remediation execution wiring (Phase 7, Tier A free-core). Mirrors +// scanfunc.go: where the scan path composes the scan-only Kensa +// (api.New{Scanner, TransportFactory}), the remediation path composes the +// FULL Kensa via pkg/kensa.DefaultWithTransportFactory, which adds the engine, +// the SQLite transaction store (rollback pre-state), the signer, and the log - +// while still driving execution over OUR credential-resolved, apply-enabled +// TransportFactory (api.Kensa.Remediate/Rollback both Connect via +// config.TransportFactory). +// +// This file is pure wiring + mapping; it never decides compliance. Kensa +// applies each rule as a Capture -> Apply -> Validate -> Commit transaction and +// auto-restores pre-state on validation failure. +// +// Spec: system-kensa-executor (remediation), api-remediation. +package kensa + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + kensaapi "github.com/Hanalyx/kensa/api" + pkgkensa "github.com/Hanalyx/kensa/pkg/kensa" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Hanalyx/openwatch/internal/credential" + owssh "github.com/Hanalyx/openwatch/internal/ssh" +) + +// RemediateFunc applies a single rule on a host and returns the per-rule +// transaction outcomes. Bound onto the Executor via WithRemediateFunc and +// driven by the remediation worker. +type RemediateFunc func(ctx context.Context, hostID uuid.UUID, ruleID string) (*RemediationRunResult, FailureReason, error) + +// RollbackFunc reverts a previously-committed transaction by its Kensa +// transaction id, using pre-state from the SQLite transaction log. +type RollbackFunc func(ctx context.Context, hostID uuid.UUID, txnID uuid.UUID) (*RollbackRunResult, FailureReason, error) + +// RemediationRunResult is the OpenWatch-side view of a kensa +// RemediationResult: one entry per transaction the engine ran for the rule. +type RemediationRunResult struct { + HostID uuid.UUID + RuleID string + Transactions []RemediationTxn + StartedAt time.Time + CompletedAt time.Time +} + +// RemediationTxn is one Kensa transaction outcome (committed / rolled_back / +// partially_applied / errored), with its signed evidence captured for the +// remediation_transactions journal. +type RemediationTxn struct { + TxnID uuid.UUID + Status string + Evidence json.RawMessage + Err string +} + +// RollbackRunResult is the OpenWatch-side view of a kensa RollbackResult. +type RollbackRunResult struct { + Status string // rolled_back | partially_restored | failed + Evidence json.RawMessage + Err string +} + +// remediateService is the slice of kensa's surface the remediation closures +// consume; the embedded *api.Kensa on pkg/kensa.Service satisfies it. +type remediateService interface { + Remediate(ctx context.Context, host kensaapi.HostConfig, rules []*kensaapi.Rule, opts ...kensaapi.RunOption) (*kensaapi.RemediationResult, error) + Rollback(ctx context.Context, host kensaapi.HostConfig, txnID uuid.UUID) (*kensaapi.RollbackResult, error) +} + +// RemediateFuncDeps mirror ScanFuncDeps and add StorePath (the SQLite +// transaction log location - must be durable so rollback pre-state survives a +// restart). +type RemediateFuncDeps struct { + Pool *pgxpool.Pool + Credentials *credential.Service + RulesDir string + HostKeyMode owssh.Mode + KnownHosts owssh.KnownHostsStore + Variables func(ctx context.Context) (map[string]string, error) + Profiles ConnProfile + Policy SudoPasswordPolicy + // StorePath is the kensa SQLite transaction-log path. Empty defaults to + // kensa's ".kensa/results.db" in the working dir (dev-only); production + // MUST set a durable path so rollback pre-state survives restarts. + StorePath string +} + +// NewProductionRemediateFunc loads the rule corpus and composes the full Kensa +// service over our credential-resolved, APPLY-enabled TransportFactory, +// returning the remediate + rollback closures the worker binds. +func NewProductionRemediateFunc(ctx context.Context, deps RemediateFuncDeps) (RemediateFunc, RollbackFunc, error) { + rules, err := pkgkensa.LoadRules(deps.RulesDir, nil, nil) + if err != nil { + return nil, nil, fmt.Errorf("kensa: load rule corpus: %w", err) + } + corpus := &corpusCache{rules: rules, dir: deps.RulesDir} + factory := &TransportFactory{ + Resolve: deps.Credentials.Resolve, + Mode: deps.HostKeyMode, + Store: deps.KnownHosts, + Profiles: deps.Profiles, + Policy: deps.Policy, + // Remediation mutates the host; the transport must permit + // control-channel-sensitive (apply) operations. + Apply: true, + } + svc, err := pkgkensa.DefaultWithTransportFactory(ctx, deps.StorePath, factory) + if err != nil { + return nil, nil, fmt.Errorf("kensa: compose remediation service: %w", err) + } + return makeRemediate(deps, corpus, svc), makeRollback(deps, svc), nil +} + +func makeRemediate(deps RemediateFuncDeps, corpus *corpusCache, svc remediateService) RemediateFunc { + return func(ctx context.Context, hostID uuid.UUID, ruleID string) (*RemediationRunResult, FailureReason, error) { + target, err := loadScanHost(ctx, deps.Pool, hostID) + if err != nil { + return nil, ReasonKensaError, err + } + rule := findRule(corpus.current(ctx, deps.Variables), ruleID) + if rule == nil { + return nil, ReasonKensaError, fmt.Errorf("kensa: rule %q not in corpus", ruleID) + } + started := time.Now().UTC() + res, err := svc.Remediate(ctx, target.hostConfig(hostID), []*kensaapi.Rule{rule}) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, "", err + } + return nil, classifyScanError(err), err + } + return &RemediationRunResult{ + HostID: hostID, + RuleID: ruleID, + Transactions: mapTxns(res.Transactions), + StartedAt: started, + CompletedAt: time.Now().UTC(), + }, "", nil + } +} + +func makeRollback(deps RemediateFuncDeps, svc remediateService) RollbackFunc { + return func(ctx context.Context, hostID uuid.UUID, txnID uuid.UUID) (*RollbackRunResult, FailureReason, error) { + target, err := loadScanHost(ctx, deps.Pool, hostID) + if err != nil { + return nil, ReasonKensaError, err + } + res, err := svc.Rollback(ctx, target.hostConfig(hostID), txnID) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, "", err + } + return nil, classifyScanError(err), err + } + status := "rolled_back" + if !res.Success { + status = "failed" + } else if res.PartialRestore { + status = "partially_restored" + } + ev, _ := json.Marshal(res) + out := &RollbackRunResult{Status: status, Evidence: ev} + if !res.Success { + out.Err = res.Detail + } + return out, "", nil + } +} + +// findRule returns the *Rule whose id matches, or nil. +func findRule(rules []*kensaapi.Rule, ruleID string) *kensaapi.Rule { + for _, r := range rules { + if r.ID == ruleID { + return r + } + } + return nil +} + +// mapTxns copies kensa TransactionResults into the OpenWatch journal shape. +func mapTxns(in []kensaapi.TransactionResult) []RemediationTxn { + out := make([]RemediationTxn, 0, len(in)) + for _, t := range in { + txn := RemediationTxn{ + TxnID: t.TransactionID, + Status: string(t.Status), + Evidence: txnEvidence(t), + } + if t.Error != nil { + txn.Err = friendlyTxnErr(t.Error.Error()) + } + out = append(out, txn) + } + return out +} + +// friendlyTxnErr translates kensa's internal preflight error +// (`mechanism "X" is not registered`) into an operator-facing message. kensa +// v0.5.1+ registers the apply handlers via pkg/kensa.Default*, so this path is +// not expected to fire; it is kept as defense-in-depth against a future +// packaging regression (the original gap was kensa #94 / v0.5.0). A live "not +// registered" still means no host change was attempted (the engine fails at +// preflight, before any apply). +func friendlyTxnErr(raw string) string { + if strings.Contains(raw, "not registered") { + return "Remediation engine unavailable in this build: the bundled Kensa version does not register host-mutation handlers. No host change was attempted." + } + return raw +} + +// txnEvidence marshals the signed evidence envelope (or a transaction +// summary) for the journal. +func txnEvidence(t kensaapi.TransactionResult) json.RawMessage { + if t.Envelope != nil { + if b, err := json.Marshal(t.Envelope); err == nil { + return b + } + } + b, _ := json.Marshal(map[string]any{ + "transaction_id": t.TransactionID.String(), + "status": string(t.Status), + "steps": len(t.Steps), + }) + return b +} diff --git a/internal/kensa/transport.go b/internal/kensa/transport.go index 39a8a477a..f72b85298 100644 --- a/internal/kensa/transport.go +++ b/internal/kensa/transport.go @@ -106,6 +106,12 @@ type TransportFactory struct { // (default-on, matching DefaultSecurity); production wires the real // systemconfig loader so a flipped switch disables scan password-sudo. Policy SudoPasswordPolicy + // Apply makes the produced transport report ControlChannelSensitive() + // true, so the Kensa engine permits APPLY steps (host mutation). The + // scan path leaves this false (read-only); only the remediation factory + // sets it true. This is the load-bearing gate that keeps a scan + // connection from ever changing a host. + Apply bool } // Connect resolves the host's credential and dials @@ -173,7 +179,7 @@ func (f *TransportFactory) Connect(ctx context.Context, host kensaapi.HostConfig } sudoPassword := sudoPasswordFor(cred, sudoAllowed) - t := &sshTransport{client: client, sudo: sudo, password: sudoPassword} + t := &sshTransport{client: client, sudo: sudo, password: sudoPassword, apply: f.Apply} // Decide how to reach root, once per connection, and reuse it for // every command. We cannot infer "sudo refused" from a real check's @@ -268,6 +274,9 @@ type sshTransport struct { sudo bool password string mode connprofile.SudoMode + // apply mirrors TransportFactory.Apply: true only for remediation + // connections, gating ControlChannelSensitive (host mutation). + apply bool } // Run executes cmd on the host, wrapping it for privilege escalation per @@ -367,7 +376,7 @@ func (t *sshTransport) Get(_ context.Context, remotePath, _ string) error { // ControlChannelSensitive reports false: the scan transport never // applies changes, so no in-flight change can disrupt it. Spec AC-21. -func (t *sshTransport) ControlChannelSensitive() bool { return false } +func (t *sshTransport) ControlChannelSensitive() bool { return t.apply } // Close terminates the underlying SSH connection. func (t *sshTransport) Close() error { return t.client.Close() } diff --git a/internal/kensa/types.go b/internal/kensa/types.go index 55126f9f4..319cd8ee1 100644 --- a/internal/kensa/types.go +++ b/internal/kensa/types.go @@ -10,7 +10,7 @@ import ( // KensaModuleVersion is the version pin recorded in the spec's context // block. AC-10 source-inspects to verify this matches the corresponding // entry in app/go.mod. -const KensaModuleVersion = "v0.5.0" +const KensaModuleVersion = "v0.5.1" // Sentinel errors returned by Executor.Run. Tests use errors.Is for // classification; the audit emission path maps each to a typed diff --git a/internal/license/features.gen.go b/internal/license/features.gen.go index a676c833e..45d630731 100644 --- a/internal/license/features.gen.go +++ b/internal/license/features.gen.go @@ -26,7 +26,7 @@ const ( AuditExport Feature = "audit_export" // Point-in-time compliance posture queries, drift forecasts, historical reconstruction from the transaction log TemporalQueries Feature = "temporal_queries" - // Apply Kensa remediation against hosts with dry-run, execute, and rollback + // Bulk and automated remediation - apply many rules / fleet-wide and policy-driven auto-remediation (single-rule manual remediation is free core) RemediationExecution Feature = "remediation_execution" // Multi-stage exception approval workflow with policy enforcement StructuredExceptions Feature = "structured_exceptions" @@ -77,7 +77,7 @@ var FeatureRegistry = map[Feature]FeatureMeta{ RemediationExecution: { ID: RemediationExecution, Tier: TierOpenWatchPlus, - Description: `Apply Kensa remediation against hosts with dry-run, execute, and rollback`, + Description: `Bulk and automated remediation - apply many rules / fleet-wide and policy-driven auto-remediation (single-rule manual remediation is free core)`, Introduced: "1.0.0", }, StructuredExceptions: { diff --git a/internal/remediation/execution.go b/internal/remediation/execution.go new file mode 100644 index 000000000..440fcf5e4 --- /dev/null +++ b/internal/remediation/execution.go @@ -0,0 +1,248 @@ +// Remediation execution lifecycle (Phase 7, Tier A free-core). These methods +// drive the approved -> executing -> executed | failed transitions and the +// executed -> rolled_back transition, and write the remediation_transactions +// journal. They are called ONLY by the remediation worker after Kensa has +// applied (or rolled back) the rule on the host — this package still NEVER +// contacts a host itself; the worker owns the *kensa.Executor. +// +// Spec: api-remediation v1.1.0. +package remediation + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// MarkExecuting transitions an 'approved' request to 'executing' under a row +// lock, so a duplicate enqueue (or a concurrent worker) cannot double-execute. +// Returns ErrWrongState when the request is not 'approved', ErrNotFound when +// the id is unknown. +func (s *Service) MarkExecuting(ctx context.Context, id uuid.UUID) (Request, error) { + return s.transition(ctx, id, StatusApproved, StatusExecuting) +} + +// MarkRolledBack transitions an 'executed' request to 'rolled_back'. Returns +// ErrWrongState when the request is not 'executed'. +func (s *Service) MarkRolledBack(ctx context.Context, id uuid.UUID) (Request, error) { + return s.transition(ctx, id, StatusExecuted, StatusRolledBack) +} + +// transition performs a guarded fromState -> toState update under FOR UPDATE. +// Unlike review() it does not touch reviewed_by/reviewed_at — execution +// transitions are system-driven, not a human review. +func (s *Service) transition(ctx context.Context, id uuid.UUID, fromState, toState Status) (Request, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return Request{}, fmt.Errorf("remediation: transition begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var status string + err = tx.QueryRow(ctx, ` + SELECT status FROM remediation_requests + WHERE id = $1 FOR UPDATE`, id).Scan(&status) + if errors.Is(err, pgx.ErrNoRows) { + return Request{}, ErrNotFound + } + if err != nil { + return Request{}, fmt.Errorf("remediation: transition lock: %w", err) + } + if Status(status) != fromState { + return Request{}, ErrWrongState + } + + row := tx.QueryRow(ctx, ` + UPDATE remediation_requests + SET status = $2, updated_at = now() + WHERE id = $1 + RETURNING `+selectCols, id, string(toState)) + rq, err := scanRequest(row) + if err != nil { + return Request{}, fmt.Errorf("remediation: transition update: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return Request{}, fmt.Errorf("remediation: transition commit: %w", err) + } + return rq, nil +} + +// RecordExecution writes the per-transaction journal rows for an executing +// request and transitions it to its final state ('executed' when at least one +// transaction committed and none errored, 'failed' otherwise), atomically. +// The request MUST be in 'executing' (set via MarkExecuting) — RecordExecution +// is idempotent on the journal: if rows already exist for this request it +// re-reads the current row rather than double-writing. +// +// finalStatus is computed from the transactions; the caller does not pass it. +// Returns the updated Request. +func (s *Service) RecordExecution(ctx context.Context, id uuid.UUID, ruleID string, txns []ExecTxn) (Request, error) { + final := StatusFailed + anyCommitted := false + anyErrored := false + failReason := "" + for _, t := range txns { + if t.Committed() { + anyCommitted = true + } + if t.Status == "errored" || t.Err != "" { + anyErrored = true + if failReason == "" && t.Err != "" { + failReason = t.Err + } + } + } + if anyCommitted && !anyErrored { + final = StatusExecuted + } + // A failed execute with no per-transaction error (e.g. the host was + // unreachable, so the journal is empty) still gets a usable reason. + if final == StatusFailed && failReason == "" { + failReason = "Remediation did not complete. No host change was committed." + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return Request{}, fmt.Errorf("remediation: record begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Guard: the row must be 'executing'. Lock it so the final transition is + // race-free against a concurrent rollback/duplicate. + var status string + err = tx.QueryRow(ctx, ` + SELECT status FROM remediation_requests + WHERE id = $1 FOR UPDATE`, id).Scan(&status) + if errors.Is(err, pgx.ErrNoRows) { + return Request{}, ErrNotFound + } + if err != nil { + return Request{}, fmt.Errorf("remediation: record lock: %w", err) + } + if Status(status) != StatusExecuting { + return Request{}, ErrWrongState + } + + // Idempotency: a re-delivered job must not double-write the journal. + var existing int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM remediation_transactions WHERE request_id = $1`, + id).Scan(&existing); err != nil { + return Request{}, fmt.Errorf("remediation: record idempotency: %w", err) + } + + if existing == 0 { + for i, t := range txns { + phase := phaseResult(t.Status) + ev := t.Evidence + if len(ev) == 0 { + ev = []byte("{}") + } + txnID := uuid.Must(uuid.NewV7()) + if _, err := tx.Exec(ctx, ` + INSERT INTO remediation_transactions + (id, request_id, ordinal, rule_id, kensa_txn_id, + phase_result, evidence, dry_run, applied_at) + VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,false,now())`, + txnID, id, i, ruleID, t.TxnID.String(), phase, ev); err != nil { + return Request{}, fmt.Errorf("remediation: insert journal: %w", err) + } + } + } + + // On failure, surface the reason in review_note so the UI shows it + // ("Failed (reason)"); on success leave the approver's note intact. + row := tx.QueryRow(ctx, ` + UPDATE remediation_requests + SET status = $2, + review_note = CASE WHEN $3 <> '' THEN $3 ELSE review_note END, + updated_at = now() + WHERE id = $1 + RETURNING `+selectCols, id, string(final), failReason) + rq, err := scanRequest(row) + if err != nil { + return Request{}, fmt.Errorf("remediation: record final update: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return Request{}, fmt.Errorf("remediation: record commit: %w", err) + } + return rq, nil +} + +// FirstCommittedTxn returns the first committed transaction id for a request, +// or false when none committed. The worker uses it to find the rollback +// handle when a rollback is requested. +func (s *Service) FirstCommittedTxn(ctx context.Context, id uuid.UUID) (uuid.UUID, bool, error) { + var raw string + err := s.pool.QueryRow(ctx, ` + SELECT kensa_txn_id FROM remediation_transactions + WHERE request_id = $1 AND phase_result = 'committed' AND kensa_txn_id IS NOT NULL + ORDER BY ordinal ASC, created_at ASC LIMIT 1`, id).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, fmt.Errorf("remediation: first committed txn: %w", err) + } + txnID, perr := uuid.Parse(raw) + if perr != nil { + return uuid.Nil, false, nil + } + return txnID, true, nil +} + +// EmitExecuted records the remediation.executed audit event. The worker calls +// this (rather than the service emitting internally) so the actor — the user +// who invoked :execute — is carried through from the HTTP request. +func (s *Service) EmitExecuted(ctx context.Context, rq Request, actor uuid.UUID, committed bool) { + if s.emit == nil { + return + } + outcome := "failed" + if committed { + outcome = "executed" + } + 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.emitAudit(ctx, auditRemediationExecuted, rq, actor, detail) +} + +// EmitRolledBack records the remediation.rolled_back audit event. +func (s *Service) EmitRolledBack(ctx context.Context, rq Request, actor uuid.UUID, status 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": status, + "status": string(rq.Status), + }) + s.emitAudit(ctx, auditRemediationRolledBack, rq, actor, detail) +} + +// phaseResult maps a kensa transaction status to the remediation_transactions +// phase_result CHECK enum (committed | rolled_back | skipped). Anything that is +// not a clean commit or rollback is recorded as 'skipped' (the journal's catch +// -all for partially_applied / errored), with the raw status preserved in the +// evidence envelope. +func phaseResult(status string) string { + switch status { + case "committed": + return "committed" + case "rolled_back": + return "rolled_back" + default: + return "skipped" + } +} diff --git a/internal/remediation/service.go b/internal/remediation/service.go new file mode 100644 index 000000000..65550fa08 --- /dev/null +++ b/internal/remediation/service.go @@ -0,0 +1,390 @@ +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 } + +// Audit-code aliases for the execution path (execution.go). Kept here next to +// emitEvent so the audit import stays in one file's mental model. +const ( + auditRemediationExecuted = audit.RemediationExecuted + auditRemediationRolledBack = audit.RemediationRolledBack +) + +// emitAudit records one remediation.* audit row with a caller-supplied detail +// payload (the execution path builds richer detail than emitEvent's fixed +// shape). actor is the user who invoked the action. +func (s *Service) emitAudit(ctx context.Context, code audit.Code, rq Request, actor uuid.UUID, detail []byte) { + if s.emit == nil { + return + } + s.emit(ctx, code, audit.Event{ + ActorType: "user", + ActorID: actor.String(), + ResourceType: "remediation_request", + ResourceID: rq.ID.String(), + Detail: detail, + }) +} + +// 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 000000000..5c04ca1cf --- /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 000000000..6aff7573d --- /dev/null +++ b/internal/remediation/types.go @@ -0,0 +1,123 @@ +// 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 by the execute path; empty until a request is executed. +type Step struct { + ID uuid.UUID + RuleID string + Mechanism string + PhaseResult *string + DryRun bool + AppliedAt *time.Time +} + +// ExecTxn is a neutral, kensa-free view of one Kensa remediation transaction +// outcome. The worker maps kensa.RemediationTxn into this shape before calling +// RecordExecution, so internal/remediation never imports internal/kensa (which +// would create an import cycle: kensa -> credential -> ... and the worker +// already depends on both). +type ExecTxn struct { + // TxnID is the Kensa transaction id (the rollback handle). Stored as + // remediation_transactions.kensa_txn_id. + TxnID uuid.UUID + // Status is the per-transaction outcome: committed | rolled_back | + // partially_applied | errored. Mapped to the phase_result CHECK enum + // (committed | rolled_back | skipped) for the journal row. + Status string + // Evidence is the signed evidence envelope (or a summary), stored in the + // remediation_transactions.evidence JSONB column. + Evidence []byte + // Err is the transaction error string, empty on success. + Err string +} + +// TxnCommitted reports whether s is the terminal "rule now passes" status. +// Kensa runs Validate before Commit, so a committed transaction means the +// rule's check passed on the host. +func (t ExecTxn) Committed() bool { return t.Status == "committed" } + +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 665f2b48b..179f9c591 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: @@ -2638,9 +2804,17 @@ type UserCreateRequest struct { Username string `json:"username"` } +// UserPasswordResetRequest defines model for UserPasswordResetRequest. +type UserPasswordResetRequest struct { + NewPassword string `json:"new_password"` +} + // UserResponse defines model for UserResponse. type UserResponse struct { - CreatedAt *time.Time `json:"created_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + + // DisabledAt Non-null when the account is disabled (cannot authenticate) + DisabledAt *time.Time `json:"disabled_at,omitempty"` Email string `json:"email"` Id openapi_types.UUID `json:"id"` LastPasswordChangeAt *time.Time `json:"last_password_change_at,omitempty"` @@ -2894,6 +3068,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 +3187,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 @@ -3041,6 +3235,9 @@ type PostUserRolesAssignJSONRequestBody = UserRoleAssignRequest // PostUserRolesUnassignJSONRequestBody defines body for PostUserRolesUnassign for application/json ContentType. type PostUserRolesUnassignJSONRequestBody = UserRoleAssignRequest +// PostUserResetPasswordJSONRequestBody defines body for PostUserResetPassword for application/json ContentType. +type PostUserResetPasswordJSONRequestBody = UserPasswordResetRequest + // ServerInterface represents all server handlers. type ServerInterface interface { // Unified Activity feed (UNION over alerts + transactions + intelligence + audit + monitoring) @@ -3154,7 +3351,7 @@ type ServerInterface interface { // Stage-0 RBAC demo; requires host:write permission // (POST /api/v1/diagnostics:require-host-write) PostDiagnosticsRequireHostWrite(w http.ResponseWriter, r *http.Request, params PostDiagnosticsRequireHostWriteParams) - // Stage-0 RBAC+license demo; requires remediation:execute + remediation_execution feature + // Stage-0 RBAC+license demo; requires remediation:execute (RBAC) + premium_diagnostics (license) // (POST /api/v1/diagnostics:require-remediation-execute) PostDiagnosticsRequireRemediationExecute(w http.ResponseWriter, r *http.Request, params PostDiagnosticsRequireRemediationExecuteParams) // Approve a requested exception @@ -3301,6 +3498,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 (preview) + // (POST /api/v1/remediation/requests/{rid}:dry-run) + DryRunRemediation(w http.ResponseWriter, r *http.Request, rid openapi_types.UUID) + // Execute an approved single-rule remediation (free core) + // (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 (free core) + // (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) @@ -3418,6 +3642,15 @@ type ServerInterface interface { // Remove a role from a user // (POST /api/v1/users/{id}/roles:unassign) PostUserRolesUnassign(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Disable a user account + // (POST /api/v1/users/{id}:disable) + PostUserDisable(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Re-enable a disabled user account + // (POST /api/v1/users/{id}:enable) + PostUserEnable(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Admin-reset a user's password (own or another's) + // (POST /api/v1/users/{id}:reset-password) + PostUserResetPassword(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) // Build/version metadata (anonymous) // (GET /api/v1/version) GetVersion(w http.ResponseWriter, r *http.Request) @@ -3649,7 +3882,7 @@ func (_ Unimplemented) PostDiagnosticsRequireHostWrite(w http.ResponseWriter, r w.WriteHeader(http.StatusNotImplemented) } -// Stage-0 RBAC+license demo; requires remediation:execute + remediation_execution feature +// Stage-0 RBAC+license demo; requires remediation:execute (RBAC) + premium_diagnostics (license) // (POST /api/v1/diagnostics:require-remediation-execute) func (_ Unimplemented) PostDiagnosticsRequireRemediationExecute(w http.ResponseWriter, r *http.Request, params PostDiagnosticsRequireRemediationExecuteParams) { w.WriteHeader(http.StatusNotImplemented) @@ -3943,6 +4176,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 (preview) +// (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 an approved single-rule remediation (free core) +// (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 (free core) +// (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) { @@ -4177,6 +4464,24 @@ func (_ Unimplemented) PostUserRolesUnassign(w http.ResponseWriter, r *http.Requ w.WriteHeader(http.StatusNotImplemented) } +// Disable a user account +// (POST /api/v1/users/{id}:disable) +func (_ Unimplemented) PostUserDisable(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Re-enable a disabled user account +// (POST /api/v1/users/{id}:enable) +func (_ Unimplemented) PostUserEnable(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Admin-reset a user's password (own or another's) +// (POST /api/v1/users/{id}:reset-password) +func (_ Unimplemented) PostUserResetPassword(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Build/version metadata (anonymous) // (GET /api/v1/version) func (_ Unimplemented) GetVersion(w http.ResponseWriter, r *http.Request) { @@ -6921,6 +7226,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) { @@ -7686,6 +8259,84 @@ func (siw *ServerInterfaceWrapper) PostUserRolesUnassign(w http.ResponseWriter, handler.ServeHTTP(w, r) } +// PostUserDisable operation middleware +func (siw *ServerInterfaceWrapper) PostUserDisable(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostUserDisable(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PostUserEnable operation middleware +func (siw *ServerInterfaceWrapper) PostUserEnable(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostUserEnable(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PostUserResetPassword operation middleware +func (siw *ServerInterfaceWrapper) PostUserResetPassword(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostUserResetPassword(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetVersion operation middleware func (siw *ServerInterfaceWrapper) GetVersion(w http.ResponseWriter, r *http.Request) { @@ -8071,6 +8722,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) }) @@ -8188,6 +8866,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/users/{id}/roles:unassign", wrapper.PostUserRolesUnassign) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/users/{id}:disable", wrapper.PostUserDisable) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/users/{id}:enable", wrapper.PostUserEnable) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/users/{id}:reset-password", wrapper.PostUserResetPassword) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/version", wrapper.GetVersion) }) @@ -8200,437 +8887,472 @@ 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==", + "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", } // 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 ea899a2f7..e6e65b929 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_rbac_test.go b/internal/server/api_rbac_test.go index 555e0b582..edbdb1311 100644 --- a/internal/server/api_rbac_test.go +++ b/internal/server/api_rbac_test.go @@ -226,18 +226,27 @@ func TestAPI_RBAC_GetPermissionsRegistry(t *testing.T) { if len(got.Roles) != 5 { t.Errorf("roles = %d, want 5", len(got.Roles)) } - // Spot-check: remediation:execute is license-gated. + // Spot-check: audit:export is license-gated to audit_export. ( + // remediation:execute used to be the spot-check, but single-rule + // execute is now FREE core and carries no license gate.) found := false for _, p := range got.Permissions { - if p.ID == "remediation:execute" { + if p.ID == "audit:export" { found = true - if p.LicenseGated == nil || *p.LicenseGated != "remediation_execution" { - t.Errorf("remediation:execute license_gated = %v, want remediation_execution", p.LicenseGated) + if p.LicenseGated == nil || *p.LicenseGated != "audit_export" { + t.Errorf("audit:export license_gated = %v, want audit_export", p.LicenseGated) } } } if !found { - t.Error("remediation:execute not surfaced via registry endpoint") + t.Error("audit:export not surfaced via registry endpoint") + } + // And confirm the free-core ungating: remediation:execute carries NO + // license gate now. + for _, p := range got.Permissions { + if p.ID == "remediation:execute" && p.LicenseGated != nil { + t.Errorf("remediation:execute license_gated = %v, want nil (free core)", *p.LicenseGated) + } } }) } diff --git a/internal/server/api_remediation_test.go b/internal/server/api_remediation_test.go new file mode 100644 index 000000000..234f61960 --- /dev/null +++ b/internal/server/api_remediation_test.go @@ -0,0 +1,238 @@ +// @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_ExecuteFreeCore +package server + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Hanalyx/openwatch/internal/auth" +) + +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: execute/rollback are FREE core (no license). A holder of +// remediation:execute executing an APPROVED request gets 202 and enqueues a +// remediation job; a caller lacking remediation:execute is 403; executing a +// non-approved (pending) request is 409; rolling back a non-executed request +// is 409. No act endpoint returns 402. +func TestAPI_Remediation_ExecuteFreeCore(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" + + // ops_lead requests; security_admin approves (separation of duties). + 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" + + // viewer has remediation:read but NOT remediation:execute -> 403 (RBAC). + o := doReq(t, asRole(t, "POST", execURL, auth.RoleViewer, map[string]any{})) + o.Body.Close() + if o.StatusCode != http.StatusForbidden { + t.Fatalf("viewer execute status = %d, want 403", o.StatusCode) + } + + // security_admin has the perm, but the request is still pending + // (not approved) -> 409 wrong_state. NOT 402. + pre := doReq(t, asRole(t, "POST", execURL, auth.RoleSecurityAdmin, map[string]any{})) + pre.Body.Close() + if pre.StatusCode != http.StatusConflict { + t.Fatalf("execute-before-approve status = %d, want 409", pre.StatusCode) + } + + // Approve it (security_admin != ops_lead requester). + ap := doReq(t, asRole(t, "POST", base+"/"+created.ID+":approve", + auth.RoleSecurityAdmin, map[string]any{"note": "ok"})) + ap.Body.Close() + if ap.StatusCode != http.StatusOK { + t.Fatalf("approve status = %d, want 200", ap.StatusCode) + } + + // Now execute -> 202 Accepted, a remediation job enqueued. + ex := doReq(t, asRole(t, "POST", execURL, auth.RoleSecurityAdmin, map[string]any{})) + var acc struct { + RequestID string `json:"request_id"` + JobID string `json:"job_id"` + Status string `json:"status"` + } + _ = json.NewDecoder(ex.Body).Decode(&acc) + ex.Body.Close() + if ex.StatusCode != http.StatusAccepted { + t.Fatalf("execute status = %d, want 202", ex.StatusCode) + } + if acc.Status != "queued" || acc.JobID == "" { + t.Errorf("execute body = %+v, want queued + job_id", acc) + } + if n := countRemediationJobs(t, pool); n != 1 { + t.Errorf("enqueued remediation jobs = %d, want 1", n) + } + + // rollback on a not-executed request -> 409 (still approved/executing). + rb := doReq(t, asRole(t, "POST", base+"/"+created.ID+":rollback", + auth.RoleSecurityAdmin, map[string]any{})) + rb.Body.Close() + if rb.StatusCode != http.StatusConflict { + t.Errorf("rollback-before-execute status = %d, want 409", rb.StatusCode) + } + }) +} + +// countRemediationJobs counts pending remediation jobs on the queue. +func countRemediationJobs(t *testing.T, pool *pgxpool.Pool) int { + t.Helper() + var n int + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM job_queue WHERE job_type = 'remediation'`).Scan(&n); err != nil { + t.Fatalf("count remediation jobs: %v", err) + } + return n +} diff --git a/internal/server/api_users_admin_test.go b/internal/server/api_users_admin_test.go new file mode 100644 index 000000000..5ea02beaa --- /dev/null +++ b/internal/server/api_users_admin_test.go @@ -0,0 +1,208 @@ +// @spec api-users +// +// Admin user-management endpoint coverage (DSN-gated): reset-password + +// disable/enable. Exercises the full stack — RBAC, real login, and session +// revocation. +// +// AC-14 TestAPI_AdminResetPassword +// AC-15 TestAPI_AdminResetOwnPassword +// AC-16 TestAPI_AdminDisableEnable (disable half) +// AC-17 TestAPI_AdminDisableEnable (enable half) +// AC-18 TestAPI_AdminDisableEnable (self-disable guard) +// AC-19 TestAPI_AdminUserMgmt_NotFoundAndRBAC +package server + +import ( + "context" + "net/http" + "testing" + + "github.com/google/uuid" + + "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/identity" + "github.com/Hanalyx/openwatch/internal/users" +) + +// getMe issues GET /auth/me carrying the given session cookie; returns the +// status. Used to prove a session is live (200) or revoked (401). +func getMeWithCookie(t *testing.T, url, cookie string) int { + t.Helper() + req, _ := http.NewRequest("GET", url+"/api/v1/auth/me", nil) + req.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: cookie}) + resp := doReq(t, req) + resp.Body.Close() + return resp.StatusCode +} + +func sessionCookie(resp *http.Response) string { + for _, c := range resp.Cookies() { + if c.Name == identity.SessionCookieName { + return c.Value + } + } + return "" +} + +// @ac AC-14 +func TestAPI_AdminResetPassword(t *testing.T) { + t.Run("api-users/AC-14", func(t *testing.T) { + url, pool := freshAPIServer(t) + svc := users.NewService(pool, nil) + target := seedAuthUser(t, svc, "resettarget", false) + _ = svc.AssignRole(context.Background(), target.ID, "viewer", nil) + base := url + "/api/v1/users/" + target.ID.String() + ":reset-password" + + // non-admin (viewer) cannot reset -> 403 + vr := doReq(t, asRole(t, "POST", base, auth.RoleViewer, map[string]any{"new_password": "whatever-strong-9Z"})) + vr.Body.Close() + if vr.StatusCode != http.StatusForbidden { + t.Fatalf("viewer reset = %d, want 403", vr.StatusCode) + } + + // admin resets to a strong new password -> 204 + newPw := "brand-new-strong-pass-9Z" + ar := doReq(t, asRole(t, "POST", base, auth.RoleAdmin, map[string]any{"new_password": newPw})) + ar.Body.Close() + if ar.StatusCode != http.StatusNoContent { + t.Fatalf("admin reset = %d, want 204", ar.StatusCode) + } + + // old password rejected, new password authenticates + oldResp := login(t, url, map[string]string{"username": target.Username, "password": target.Password}) + oldResp.Body.Close() + if oldResp.StatusCode != http.StatusUnauthorized { + t.Errorf("login with old password = %d, want 401", oldResp.StatusCode) + } + newResp := login(t, url, map[string]string{"username": target.Username, "password": newPw}) + newResp.Body.Close() + if newResp.StatusCode != http.StatusOK { + t.Errorf("login with new password = %d, want 200", newResp.StatusCode) + } + + // a password that fails policy -> 400, old (new) password still works + short := doReq(t, asRole(t, "POST", base, auth.RoleAdmin, map[string]any{"new_password": "abc"})) + short.Body.Close() + if short.StatusCode != http.StatusBadRequest { + t.Errorf("too-short reset = %d, want 400", short.StatusCode) + } + }) +} + +// @ac AC-15 +func TestAPI_AdminResetOwnPassword(t *testing.T) { + t.Run("api-users/AC-15", func(t *testing.T) { + url, _ := freshAPIServer(t) + adminID := roleUserIDs[auth.RoleAdmin] + base := url + "/api/v1/users/" + adminID.String() + ":reset-password" + + // admin resets their OWN password without a current password -> 204 + r := doReq(t, asRole(t, "POST", base, auth.RoleAdmin, map[string]any{"new_password": "my-own-fresh-pass-15chars"})) + r.Body.Close() + if r.StatusCode != http.StatusNoContent { + t.Fatalf("admin self-reset = %d, want 204", r.StatusCode) + } + }) +} + +// @ac AC-16 +// @ac AC-17 +// @ac AC-18 +func TestAPI_AdminDisableEnable(t *testing.T) { + // Shared setup: a viewer target with a live session, used to prove that + // disable revokes the session. The three ACs run as ORDERED subtests + // (no t.Parallel), so AC-17's enable observes AC-16's disable. Each AC + // carries its own api-users/AC-NN token so the coverage gate credits it. + url, pool := freshAPIServer(t) + svc := users.NewService(pool, nil) + target := seedAuthUser(t, svc, "disabletarget", false) + _ = svc.AssignRole(context.Background(), target.ID, "viewer", nil) + + lr := login(t, url, map[string]string{"username": target.Username, "password": target.Password}) + lr.Body.Close() + if lr.StatusCode != http.StatusOK { + t.Fatalf("target login = %d, want 200", lr.StatusCode) + } + cookie := sessionCookie(lr) + if cookie == "" { + t.Fatal("no session cookie from target login") + } + if code := getMeWithCookie(t, url, cookie); code != http.StatusOK { + t.Fatalf("target /me before disable = %d, want 200", code) + } + + t.Run("api-users/AC-16", func(t *testing.T) { + // admin disables target -> 200, disabled_at set + dr := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+target.ID.String()+":disable", auth.RoleAdmin, nil)) + dr.Body.Close() + if dr.StatusCode != http.StatusOK { + t.Fatalf("disable = %d, want 200", dr.StatusCode) + } + // existing session revoked + login now blocked + if code := getMeWithCookie(t, url, cookie); code != http.StatusUnauthorized { + t.Errorf("target /me after disable = %d, want 401 (session revoked)", code) + } + blocked := login(t, url, map[string]string{"username": target.Username, "password": target.Password}) + blocked.Body.Close() + if blocked.StatusCode != http.StatusUnauthorized { + t.Errorf("disabled login = %d, want 401", blocked.StatusCode) + } + }) + + t.Run("api-users/AC-17", func(t *testing.T) { + // enable -> clears disabled_at; the user can authenticate again + er := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+target.ID.String()+":enable", auth.RoleAdmin, nil)) + er.Body.Close() + if er.StatusCode != http.StatusOK { + t.Fatalf("enable = %d, want 200", er.StatusCode) + } + reLogin := login(t, url, map[string]string{"username": target.Username, "password": target.Password}) + reLogin.Body.Close() + if reLogin.StatusCode != http.StatusOK { + t.Errorf("login after enable = %d, want 200", reLogin.StatusCode) + } + }) + + t.Run("api-users/AC-18", func(t *testing.T) { + // admin cannot disable their own account -> 409 cannot_disable_self + self := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+roleUserIDs[auth.RoleAdmin].String()+":disable", auth.RoleAdmin, nil)) + self.Body.Close() + if self.StatusCode != http.StatusConflict { + t.Errorf("self-disable = %d, want 409", self.StatusCode) + } + }) +} + +// @ac AC-19 +func TestAPI_AdminUserMgmt_NotFoundAndRBAC(t *testing.T) { + t.Run("api-users/AC-19", func(t *testing.T) { + url, _ := freshAPIServer(t) + ghost := uuid.Must(uuid.NewV7()).String() + + // unknown user -> 404 on all three + for _, action := range []string{":reset-password", ":disable", ":enable"} { + body := map[string]any(nil) + if action == ":reset-password" { + body = map[string]any{"new_password": "some-strong-pass-9Z"} + } + r := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+ghost+action, auth.RoleAdmin, body)) + r.Body.Close() + if r.StatusCode != http.StatusNotFound { + t.Errorf("%s unknown user = %d, want 404", action, r.StatusCode) + } + } + + // non-admin (security_admin has user:write but NOT admin:user_manage) -> 403 + for _, action := range []string{":reset-password", ":disable", ":enable"} { + body := map[string]any(nil) + if action == ":reset-password" { + body = map[string]any{"new_password": "some-strong-pass-9Z"} + } + r := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+ghost+action, auth.RoleSecurityAdmin, body)) + r.Body.Close() + if r.StatusCode != http.StatusForbidden { + t.Errorf("%s as security_admin = %d, want 403 (lacks admin:user_manage)", action, r.StatusCode) + } + } + }) +} diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 2910446c3..5775d58af 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -46,6 +46,16 @@ func (h *handlers) PostAuthLogin(w http.ResponseWriter, r *http.Request) { return } + // A disabled account cannot authenticate. The client gets the same + // generic "invalid username or password" (no account-state enumeration); + // the audit trail records the specific reason. + if u.DisabledAt != nil { + emitLoginFailure(r, "account_disabled", req.Username) + writeError(w, http.StatusUnauthorized, "auth.invalid_credentials", "client", + "invalid username or password", false) + return + } + // Check MFA enrollment. If enrolled, the otp is required. enrolled, err := mfaEnrolled(r.Context(), h, u.ID) if err != nil { diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 0b4046881..9b8803ebf 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. @@ -570,6 +576,13 @@ func (h *handlers) PostDiagnosticsRequireRemediationExecute(w http.ResponseWrite if denied := auth.EnforcePermission(w, r, auth.RemediationExecute); denied { return } + // Stage-0 walking-skeleton demo of the RBAC-then-license ordering. Since + // remediation:execute became free core (single-rule manual remediation is + // no longer license_gated), this demo gates on premium_diagnostics to keep + // exercising the "RBAC passes, license fails" path (system-rbac AC-10). + if denied := license.EnforceFeature(w, r, license.PremiumDiagnostics); denied { + return + } requireDiagnosticEcho(w, r, h.pool, "diagnostics:require-remediation-execute") } diff --git a/internal/server/remediation_handlers.go b/internal/server/remediation_handlers.go new file mode 100644 index 000000000..fa83b0e9c --- /dev/null +++ b/internal/server/remediation_handlers.go @@ -0,0 +1,429 @@ +// 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 :execute and :rollback are FREE core (Tier A): they enforce the +// dangerous-but-ungated remediation:execute / remediation:rollback permission, +// guard the request's lifecycle state, and enqueue an HMAC-signed remediation +// job the worker drains (202 Accepted). :dry-run stays 501 (not yet built) but +// is no longer license-gated. +// +// 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/audit" + "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/host" + "github.com/Hanalyx/openwatch/internal/queue" + "github.com/Hanalyx/openwatch/internal/remediation" + "github.com/Hanalyx/openwatch/internal/server/api" + "github.com/Hanalyx/openwatch/internal/worker" +) + +// 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) +} + +// DryRunRemediation implements api.ServerInterface. Dry-run is not yet built +// (501); it requires remediation:execute but is no longer license-gated. +func (h *handlers) DryRunRemediation(w http.ResponseWriter, r *http.Request, _ openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.RemediationExecute); denied { + return + } + writeError(w, http.StatusNotImplemented, "remediation.not_implemented", "server", + "remediation dry-run is not yet implemented", false) +} + +// ExecuteRemediation implements api.ServerInterface (FREE core, Tier A). +// Requires remediation:execute. The request must be in 'approved' state (409 +// otherwise). Enqueues an HMAC-signed remediation job (202 Accepted); the +// worker applies the rule, records the journal, transitions executed|failed, +// and flips the rule to pass on a committed run. Spec api-remediation AC-06. +func (h *handlers) ExecuteRemediation(w http.ResponseWriter, r *http.Request, rid openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.RemediationExecute); denied { + return + } + if !h.remediationSvcReady(w) { + return + } + if h.scanQueueKey == nil { + writeError(w, http.StatusServiceUnavailable, "server.unavailable", "server", + "remediation queue not wired", true) + return + } + ctx := r.Context() + rq, err := h.remediationSvc.Get(ctx, uuid.UUID(rid)) + if mapRemediationErr(w, err) { + return + } + if rq.Status != remediation.StatusApproved { + writeError(w, http.StatusConflict, "remediation.wrong_state", "client", + "only an approved request can be executed", false) + return + } + + body := worker.MarshalRemediationJob(h.scanQueueKey, worker.RemediationPayload{ + RequestID: rq.ID, + HostID: rq.HostID, + RuleID: rq.RuleID, + Action: worker.RemediationActionExecute, + }) + jobID, err := queue.Enqueue(ctx, h.pool, worker.RemediationJobType, body) + if err != nil { + writeError(w, http.StatusInternalServerError, "server.error", "server", + "enqueue failed", true) + return + } + h.emitRemediationActQueued(ctx, r, rq, worker.RemediationActionExecute, jobID) + h.writeRemediationAccepted(w, rq, jobID) +} + +// RollbackRemediation implements api.ServerInterface (FREE core, Tier A). +// Requires remediation:rollback. The request must be in 'executed' state (409 +// otherwise). Enqueues a rollback job (202 Accepted). Spec api-remediation AC-06. +func (h *handlers) RollbackRemediation(w http.ResponseWriter, r *http.Request, rid openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.RemediationRollback); denied { + return + } + if !h.remediationSvcReady(w) { + return + } + if h.scanQueueKey == nil { + writeError(w, http.StatusServiceUnavailable, "server.unavailable", "server", + "remediation queue not wired", true) + return + } + ctx := r.Context() + rq, err := h.remediationSvc.Get(ctx, uuid.UUID(rid)) + if mapRemediationErr(w, err) { + return + } + if rq.Status != remediation.StatusExecuted { + writeError(w, http.StatusConflict, "remediation.wrong_state", "client", + "only an executed request can be rolled back", false) + return + } + + body := worker.MarshalRemediationJob(h.scanQueueKey, worker.RemediationPayload{ + RequestID: rq.ID, + HostID: rq.HostID, + RuleID: rq.RuleID, + Action: worker.RemediationActionRollback, + }) + jobID, err := queue.Enqueue(ctx, h.pool, worker.RemediationJobType, body) + if err != nil { + writeError(w, http.StatusInternalServerError, "server.error", "server", + "enqueue failed", true) + return + } + h.emitRemediationActQueued(ctx, r, rq, worker.RemediationActionRollback, jobID) + h.writeRemediationAccepted(w, rq, jobID) +} + +// writeRemediationAccepted writes the 202 envelope for a queued act. The body +// echoes the request id + the enqueued job id so the client can poll. +func (h *handlers) writeRemediationAccepted(w http.ResponseWriter, rq remediation.Request, jobID uuid.UUID) { + writeJSON(w, http.StatusAccepted, map[string]any{ + "request_id": rq.ID.String(), + "job_id": jobID.String(), + "status": "queued", + }) +} + +// emitRemediationActQueued records who asked for an execute/rollback, from +// where. The terminal remediation.executed / remediation.rolled_back audit +// follows from the worker once the job completes. +func (h *handlers) emitRemediationActQueued(ctx context.Context, r *http.Request, + rq remediation.Request, action string, jobID uuid.UUID) { + ident := auth.FromContext(r.Context()) + detail, _ := json.Marshal(map[string]string{ + "request_id": rq.ID.String(), + "host_id": rq.HostID.String(), + "rule_id": rq.RuleID, + "action": action, + "job_id": jobID.String(), + "outcome": "queued", + }) + code := audit.RemediationExecuted + if action == worker.RemediationActionRollback { + code = audit.RemediationRolledBack + } + audit.Emit(ctx, code, audit.Event{ + ActorType: "user", + ActorID: ident.ID, + ResourceType: "remediation_request", + ResourceID: rq.ID.String(), + Detail: detail, + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 48c0f6fd7..8aba711b2 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. @@ -190,6 +199,16 @@ func (s *Server) WithScanWorker(sw *worker.ScanWorker) *Server { return s } +// WithRemediationWorker registers the remediation processor on the in-process +// job worker, so "remediation" jobs claimed by the serve process execute +// instead of dead-ending. Spec api-remediation. +func (s *Server) WithRemediationWorker(rw *worker.RemediationWorker) *Server { + if s.wkr != nil { + s.wkr.WithRemediationProcessor(rw) + } + return s +} + // New constructs a Server from validated config and DB pool. The returned // Server has the foundation middleware chain mounted (correlation first, // then idempotency) and the Stage-0 API routes generated from diff --git a/internal/server/users_admin_handlers.go b/internal/server/users_admin_handlers.go new file mode 100644 index 000000000..fccd63778 --- /dev/null +++ b/internal/server/users_admin_handlers.go @@ -0,0 +1,110 @@ +// Admin user-management HTTP surface: reset another user's (or one's own) +// password, and disable / enable an account. Thin handlers over +// internal/users - RBAC (admin:user_manage), the self-disable lockout guard, +// and error-to-status mapping live here; the password policy, session +// revocation, and disabled-state semantics live in the service. +// +// Spec: specs/api/users.spec.yaml (admin reset-password + disable/enable). + +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/google/uuid" + openapitypes "github.com/oapi-codegen/runtime/types" + + "github.com/Hanalyx/openwatch/internal/audit" + "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/identity" + "github.com/Hanalyx/openwatch/internal/server/api" + "github.com/Hanalyx/openwatch/internal/users" +) + +// mapUserAdminErr translates a users service error to an HTTP response. +// Returns true when it handled (wrote) the error. +func mapUserAdminErr(w http.ResponseWriter, err error) bool { + switch { + case err == nil: + return false + case errors.Is(err, users.ErrUserNotFound): + writeError(w, http.StatusNotFound, "users.not_found", "client", "user not found", false) + case errors.Is(err, identity.ErrPasswordTooShort), + errors.Is(err, identity.ErrPasswordTooLong), + errors.Is(err, identity.ErrPasswordBreached): + writeError(w, http.StatusBadRequest, "validation.password_policy", "client", err.Error(), false) + default: + writeError(w, http.StatusInternalServerError, "server.error", "server", + "user operation failed", true) + } + return true +} + +// PostUserResetPassword implements api.ServerInterface. +// Spec api-users (admin reset-password). +func (h *handlers) PostUserResetPassword(w http.ResponseWriter, r *http.Request, id openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.AdminUserManage); denied { + return + } + var req api.UserPasswordResetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "validation.field_required", "client", + "malformed request body", false) + return + } + if err := h.users.AdminResetPassword(r.Context(), uuid.UUID(id), req.NewPassword); mapUserAdminErr(w, err) { + return + } + caller := auth.FromContext(r.Context()).ID + emitAudit(r, audit.AdminUserPasswordReset, caller, map[string]any{ + "target_user_id": id.String(), + "self": caller == id.String(), + }) + w.WriteHeader(http.StatusNoContent) +} + +// PostUserDisable implements api.ServerInterface. +// Spec api-users (disable/enable). +func (h *handlers) PostUserDisable(w http.ResponseWriter, r *http.Request, id openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.AdminUserManage); denied { + return + } + caller := auth.FromContext(r.Context()).ID + // Lockout prevention: an admin must not disable their own account. + if caller == id.String() { + writeError(w, http.StatusConflict, "users.cannot_disable_self", "client", + "you cannot disable your own account", false) + return + } + if err := h.users.Disable(r.Context(), uuid.UUID(id)); mapUserAdminErr(w, err) { + return + } + emitAudit(r, audit.AdminUserDisabled, caller, map[string]any{"target_user_id": id.String()}) + h.writeUser(w, r, uuid.UUID(id)) +} + +// PostUserEnable implements api.ServerInterface. +// Spec api-users (disable/enable). +func (h *handlers) PostUserEnable(w http.ResponseWriter, r *http.Request, id openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.AdminUserManage); denied { + return + } + if err := h.users.Enable(r.Context(), uuid.UUID(id)); mapUserAdminErr(w, err) { + return + } + caller := auth.FromContext(r.Context()).ID + emitAudit(r, audit.AdminUserEnabled, caller, map[string]any{"target_user_id": id.String()}) + h.writeUser(w, r, uuid.UUID(id)) +} + +// writeUser re-reads the user and writes it as a 200 UserResponse. Used by +// disable/enable so the client gets the updated disabled_at without a refetch. +func (h *handlers) writeUser(w http.ResponseWriter, r *http.Request, id uuid.UUID) { + u, err := h.users.GetUserByID(r.Context(), id) + if mapUserAdminErr(w, err) { + return + } + writeJSON(w, http.StatusOK, userResponse(u)) +} diff --git a/internal/server/users_handlers.go b/internal/server/users_handlers.go index 2f2e352a0..d84d265c2 100644 --- a/internal/server/users_handlers.go +++ b/internal/server/users_handlers.go @@ -255,6 +255,9 @@ func userResponse(u users.User) api.UserResponse { if u.Roles != nil { resp.Roles = &u.Roles } + if u.DisabledAt != nil { + resp.DisabledAt = u.DisabledAt + } return resp } diff --git a/internal/users/roles.go b/internal/users/roles.go index 996e9df15..e0d9ed25b 100644 --- a/internal/users/roles.go +++ b/internal/users/roles.go @@ -42,7 +42,7 @@ func (s *Service) ListUsers(ctx context.Context) ([]User, error) { // The roles aggregate is a correlated subquery (one row per user, no // join fan-out) so a user with no roles still lists with an empty array. const stmt = ` - SELECT u.id, u.username, u.email, u.last_password_change_at, u.created_at, u.updated_at, + SELECT u.id, u.username, u.email, u.last_password_change_at, u.created_at, u.updated_at, u.disabled_at, COALESCE(ARRAY( SELECT ur.role_id FROM user_roles ur WHERE ur.user_id = u.id ORDER BY ur.role_id @@ -60,7 +60,7 @@ func (s *Service) ListUsers(ctx context.Context) ([]User, error) { var u User if err := rows.Scan( &u.ID, &u.Username, &u.Email, - &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, &u.Roles, + &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, &u.DisabledAt, &u.Roles, ); err != nil { return nil, fmt.Errorf("users: scan: %w", err) } diff --git a/internal/users/users.go b/internal/users/users.go index 9d4b14521..8a832ce89 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -27,6 +27,12 @@ var ( ErrUserNotFound = errors.New("users: not found") ErrUnknownRole = errors.New("users: role does not exist") ErrUserHasNoRoles = errors.New("users: user has no roles assigned") + // ErrUserDisabled is returned when an operation targets a disabled + // account, or (for the login path) when a disabled user authenticates. + ErrUserDisabled = errors.New("users: account is disabled") + // ErrCannotDisableSelf guards an admin from disabling their own account + // (lockout prevention). + ErrCannotDisableSelf = errors.New("users: cannot disable your own account") ) // User is the safe shape returned by every read API. PasswordHash is @@ -45,6 +51,10 @@ type User struct { LastPasswordChangeAt time.Time CreatedAt time.Time UpdatedAt time.Time + // DisabledAt is non-nil when the account is disabled (cannot + // authenticate). Distinct from a soft-delete: a disabled account is + // recoverable via Enable. + DisabledAt *time.Time // Roles holds the role IDs assigned to the user (from user_roles). // Populated by ListUsers via an aggregate; other lookups (login path, // GetUserByID) leave it nil since they do not need the membership join. @@ -185,7 +195,7 @@ func (s *Service) CreateFederatedUser(ctx context.Context, username, email strin // Spec AC-04. func (s *Service) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { const stmt = ` - SELECT id, username, email, last_password_change_at, created_at, updated_at + SELECT id, username, email, last_password_change_at, created_at, updated_at, disabled_at FROM users WHERE id = $1 AND deleted_at IS NULL` return s.queryOne(ctx, stmt, id) @@ -197,7 +207,7 @@ func (s *Service) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { // Spec AC-05. func (s *Service) GetUserByUsername(ctx context.Context, username string) (User, error) { const stmt = ` - SELECT id, username, email, last_password_change_at, created_at, updated_at + SELECT id, username, email, last_password_change_at, created_at, updated_at, disabled_at FROM users WHERE username = $1 AND deleted_at IS NULL` return s.queryOne(ctx, stmt, username) @@ -209,14 +219,14 @@ func (s *Service) GetUserByUsername(ctx context.Context, username string) (User, // returns the hash to the caller. func (s *Service) VerifyUserPassword(ctx context.Context, username, password string) (User, error) { const stmt = ` - SELECT id, username, email, last_password_change_at, created_at, updated_at, password_hash + SELECT id, username, email, last_password_change_at, created_at, updated_at, disabled_at, password_hash FROM users WHERE username = $1 AND deleted_at IS NULL` var u User var hash string err := s.pool.QueryRow(ctx, stmt, username).Scan( &u.ID, &u.Username, &u.Email, - &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, + &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, &u.DisabledAt, &hash, ) if err != nil { @@ -286,6 +296,64 @@ func (s *Service) SoftDelete(ctx context.Context, id uuid.UUID) error { return nil } +// AdminResetPassword sets a user's password on an administrator's authority: +// unlike the self-service password change it does NOT require the current +// password. The new password still runs through the role-aware policy + +// breach screen (via UpdatePassword). The target's active sessions are then +// revoked so they must re-authenticate with the new password. +// +// Spec api-users (admin reset-password). +func (s *Service) AdminResetPassword(ctx context.Context, id uuid.UUID, newPassword string) error { + if err := s.UpdatePassword(ctx, id, newPassword); err != nil { + return err + } + if err := identity.RevokeAllSessionsForUser(ctx, s.pool, id); err != nil { + return fmt.Errorf("users: revoke sessions after reset: %w", err) + } + return nil +} + +// Disable marks an account disabled (disabled_at = now). A disabled user +// cannot authenticate: the login path rejects them and disabling revokes +// their active sessions so the cutoff is immediate. Idempotent: disabling an +// already-disabled user refreshes the timestamp. ErrUserNotFound for unknown +// or soft-deleted users. +// +// Spec api-users (disable/enable). +func (s *Service) Disable(ctx context.Context, id uuid.UUID) error { + const stmt = `UPDATE users SET disabled_at = now(), updated_at = now() + WHERE id = $1 AND deleted_at IS NULL` + tag, err := s.pool.Exec(ctx, stmt, id) + if err != nil { + return fmt.Errorf("users: disable: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrUserNotFound + } + if err := identity.RevokeAllSessionsForUser(ctx, s.pool, id); err != nil { + return fmt.Errorf("users: revoke sessions on disable: %w", err) + } + return nil +} + +// Enable clears the disabled flag. The user can authenticate again with a +// fresh login; sessions revoked while disabled stay dead. ErrUserNotFound for +// unknown or soft-deleted users. +// +// Spec api-users (disable/enable). +func (s *Service) Enable(ctx context.Context, id uuid.UUID) error { + const stmt = `UPDATE users SET disabled_at = NULL, updated_at = now() + WHERE id = $1 AND deleted_at IS NULL` + tag, err := s.pool.Exec(ctx, stmt, id) + if err != nil { + return fmt.Errorf("users: enable: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrUserNotFound + } + return nil +} + // AssignRole inserts a user_roles row. Role must exist; FK enforcement // is mandatory (spec C-04). Idempotent — re-assigning an existing role // is a no-op. @@ -387,7 +455,7 @@ func (s *Service) queryOne(ctx context.Context, stmt string, arg any) (User, err var u User err := s.pool.QueryRow(ctx, stmt, arg).Scan( &u.ID, &u.Username, &u.Email, - &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, + &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, &u.DisabledAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/worker/remediation_payload.go b/internal/worker/remediation_payload.go new file mode 100644 index 000000000..f8cad26e4 --- /dev/null +++ b/internal/worker/remediation_payload.go @@ -0,0 +1,171 @@ +// JSONB payload + HMAC signing for remediation jobs. +// +// Mirrors the scan-job envelope (scheduler.JobPayload + hex HMAC tag), but the +// remediation payload carries its own load-bearing fields — request_id, +// rule_id, action, and (for rollback) txn_id — so it needs its own canonical +// encoding to HMAC-sign. We reuse the SAME queue key the scheduler derives +// (scheduler.DeriveQueueKey over the credential DEK); the encoding here is +// purpose-distinct from the scan encoding, so a scan tag can never validate a +// remediation payload and vice versa. +// +// The worker verifies the HMAC BEFORE any host-mutating side effect, exactly +// like the scan path (system-worker-subcommand C-02). +package worker + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// RemediationJobType is the queue.Job.JobType for a remediation execute or +// rollback. Distinct from ScanJobType so the dispatcher routes by type. +const RemediationJobType = "remediation" + +// Remediation action discriminators carried in the payload. +const ( + RemediationActionExecute = "execute" + RemediationActionRollback = "rollback" +) + +// remediationHMACDomain is prepended to the canonical encoding so a +// remediation tag is domain-separated from a scan tag even though both use the +// same key. Changing it is a key-rotation boundary. +const remediationHMACDomain = "openwatch-remediation-v1" + +// RemediationPayload is the typed payload of a remediation job. +type RemediationPayload struct { + RequestID uuid.UUID + HostID uuid.UUID + RuleID string + Action string // execute | rollback + TxnID uuid.UUID // rollback only; uuid.Nil for execute +} + +// remediationJobBody is the wire shape stored in the queue row's JSONB. Mirror +// of scanJobBody: string ids + hex hmac. +type remediationJobBody struct { + RequestID string `json:"request_id"` + HostID string `json:"host_id"` + RuleID string `json:"rule_id"` + Action string `json:"action"` + TxnID string `json:"txn_id,omitempty"` + HMAC string `json:"hmac"` +} + +// encodeRemediation returns the canonical byte representation the HMAC signs. +// Layout (big-endian lengths): domain || request_id || host_id || txn_id || +// len(action) || action || len(rule_id) || rule_id. UUIDs are raw 16 bytes. +func encodeRemediation(p RemediationPayload) []byte { + action := []byte(p.Action) + rule := []byte(p.RuleID) + buf := make([]byte, 0, len(remediationHMACDomain)+16*3+4+len(action)+4+len(rule)) + buf = append(buf, []byte(remediationHMACDomain)...) + buf = append(buf, p.RequestID[:]...) + buf = append(buf, p.HostID[:]...) + buf = append(buf, p.TxnID[:]...) + lenBuf := make([]byte, 4) + binary.BigEndian.PutUint32(lenBuf, uint32(len(action))) //nolint:gosec // bounded by field + buf = append(buf, lenBuf...) + buf = append(buf, action...) + binary.BigEndian.PutUint32(lenBuf, uint32(len(rule))) //nolint:gosec // bounded by field + buf = append(buf, lenBuf...) + buf = append(buf, rule...) + return buf +} + +// signRemediation computes the HMAC-SHA256 tag of p under key. +func signRemediation(key []byte, p RemediationPayload) [sha256.Size]byte { + mac := hmac.New(sha256.New, key) + _, _ = mac.Write(encodeRemediation(p)) + var out [sha256.Size]byte + copy(out[:], mac.Sum(nil)) + return out +} + +// verifyRemediation reports whether tag is a valid HMAC for p under key +// (constant-time). +func verifyRemediation(key []byte, p RemediationPayload, tag [sha256.Size]byte) bool { + expected := signRemediation(key, p) + return subtle.ConstantTimeCompare(tag[:], expected[:]) == 1 +} + +// MarshalRemediationJob builds the signed JSONB body for queue.Enqueue. Used +// by the HTTP execute/rollback handlers so the worker can verify on claim. +func MarshalRemediationJob(key []byte, p RemediationPayload) map[string]any { + tag := signRemediation(key, p) + body := map[string]any{ + "request_id": p.RequestID.String(), + "host_id": p.HostID.String(), + "rule_id": p.RuleID, + "action": p.Action, + "hmac": fmt.Sprintf("%x", tag[:]), + } + if p.Action == RemediationActionRollback { + body["txn_id"] = p.TxnID.String() + } + return body +} + +var ( + errRemMissingHMAC = errors.New("worker: remediation job payload missing hmac") + errRemMalformed = errors.New("worker: remediation job payload malformed") + errRemUnknownAction = errors.New("worker: remediation job payload unknown action") +) + +// parseRemediationPayload decodes a queue row's JSONB into a RemediationPayload +// + raw HMAC tag. Mirrors parseScanPayload's error taxonomy. +func parseRemediationPayload(raw []byte) (RemediationPayload, [sha256.Size]byte, error) { + var zero [sha256.Size]byte + var body remediationJobBody + if err := json.Unmarshal(raw, &body); err != nil { + return RemediationPayload{}, zero, fmt.Errorf("%w: unmarshal: %v", errRemMalformed, err) + } + requestID, err := uuid.Parse(body.RequestID) + if err != nil { + return RemediationPayload{}, zero, fmt.Errorf("%w: request_id: %v", errRemMalformed, err) + } + hostID, err := uuid.Parse(body.HostID) + if err != nil { + return RemediationPayload{}, zero, fmt.Errorf("%w: host_id: %v", errRemMalformed, err) + } + switch body.Action { + case RemediationActionExecute, RemediationActionRollback: + default: + return RemediationPayload{}, zero, fmt.Errorf("%w: %q", errRemUnknownAction, body.Action) + } + p := RemediationPayload{ + RequestID: requestID, + HostID: hostID, + RuleID: body.RuleID, + Action: body.Action, + } + if body.Action == RemediationActionRollback { + txnID, terr := uuid.Parse(body.TxnID) + if terr != nil { + return RemediationPayload{}, zero, fmt.Errorf("%w: txn_id: %v", errRemMalformed, terr) + } + p.TxnID = txnID + } + if body.HMAC == "" { + return RemediationPayload{}, zero, errRemMissingHMAC + } + tagBytes, err := hex.DecodeString(body.HMAC) + if err != nil { + return RemediationPayload{}, zero, fmt.Errorf("%w: hmac hex: %v", errRemMalformed, err) + } + if len(tagBytes) != sha256.Size { + return RemediationPayload{}, zero, fmt.Errorf("%w: hmac length: got %d want %d", + errRemMalformed, len(tagBytes), sha256.Size) + } + var tag [sha256.Size]byte + copy(tag[:], tagBytes) + return p, tag, nil +} diff --git a/internal/worker/remediation_worker.go b/internal/worker/remediation_worker.go new file mode 100644 index 000000000..60a4e6241 --- /dev/null +++ b/internal/worker/remediation_worker.go @@ -0,0 +1,367 @@ +// Package worker — production remediation-job consumer. +// +// RemediationWorker mirrors ScanWorker for the queued single-rule remediation +// path (Phase 7, Tier A free-core). It: +// +// 1. Parses + HMAC-verifies the remediation payload BEFORE any side effect. +// 2. Loads the approved (execute) / executed (rollback) request and guards +// its state via the remediation service's row-locked transitions. +// 3. Calls executor.Remediate / executor.Rollback — which share the host's +// per-host inFlight guard with scans (a host is never scanned + remediated +// at the same instant). +// 4. Writes the remediation_transactions journal and transitions the request +// to its terminal state (executed | failed | rolled_back). +// 5. On a committed execute, flips THAT one rule to pass in host_rule_state +// via the transaction-log Writer (Kensa ran Validate before Commit, so the +// rule now passes — no full re-scan needed) so the compliance score moves. +// 6. Publishes remediation.completed on the event bus, emits the audit event, +// and marks the queue row complete. +// +// The worker owns the *kensa.Executor and the transaction-log Writer; the +// remediation service stays host-free and import-cycle-free (the worker maps +// kensa.RemediationTxn -> remediation.ExecTxn at this boundary). +// +// Spec: api-remediation, system-worker-subcommand. +package worker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Hanalyx/openwatch/internal/audit" + "github.com/Hanalyx/openwatch/internal/eventbus" + "github.com/Hanalyx/openwatch/internal/kensa" + "github.com/Hanalyx/openwatch/internal/queue" + "github.com/Hanalyx/openwatch/internal/remediation" + "github.com/Hanalyx/openwatch/internal/transactionlog" +) + +// RemediationWorker processes "remediation" queue jobs. One per process, +// constructed at boot and held until shutdown. It is dispatched to by both the +// in-process Worker.process and the dedicated ScanWorker.ProcessJob (each +// routes by JobType so one worker drains the queue). +type RemediationWorker struct { + pool *pgxpool.Pool + executor *kensa.Executor + svc *remediation.Service + writer *transactionlog.Writer + queueKey []byte + bus *eventbus.Bus // nil = no remediation.completed publication (dedicated worker) + emit EmitFunc + clock func() time.Time +} + +// RemediationConfig is the constructor bundle. All fields except Bus/Clock/Emit +// are required. +type RemediationConfig struct { + Pool *pgxpool.Pool + Executor *kensa.Executor + Service *remediation.Service + Writer *transactionlog.Writer + QueueKey []byte + Bus *eventbus.Bus + Emit EmitFunc + Clock func() time.Time +} + +// NewRemediationWorker wires a RemediationWorker. +func NewRemediationWorker(cfg RemediationConfig) *RemediationWorker { + if cfg.Clock == nil { + cfg.Clock = time.Now + } + if cfg.Emit == nil { + cfg.Emit = audit.Emit + } + return &RemediationWorker{ + pool: cfg.Pool, + executor: cfg.Executor, + svc: cfg.Service, + writer: cfg.Writer, + queueKey: cfg.QueueKey, + bus: cfg.Bus, + emit: cfg.Emit, + clock: cfg.Clock, + } +} + +// ProcessJob runs the full remediation pipeline for one job. Recovers from +// panics so a rogue remediation does not take down the worker. +func (w *RemediationWorker) ProcessJob(ctx context.Context, j *queue.Job) { + defer func() { + if r := recover(); r != nil { + slog.ErrorContext(ctx, "worker panic during remediation", + slog.String("job_id", j.ID.String()), + slog.Any("panic", r)) + _ = queue.Fail(ctx, w.pool, j.ID, fmt.Sprintf("worker panic: %v", r)) + } + }() + + if j.JobType != RemediationJobType { + _ = queue.Fail(ctx, w.pool, j.ID, fmt.Sprintf("unsupported job_type %q (remediation worker)", j.JobType)) + return + } + + // Parse + HMAC-verify BEFORE any side effect. + payload, tag, err := parseRemediationPayload(j.Payload) + if err != nil { + w.emitHMACRejected(ctx, j.ID, payload, err) + _ = queue.Fail(ctx, w.pool, j.ID, fmt.Sprintf("hmac_rejected: %v", err)) + return + } + if !verifyRemediation(w.queueKey, payload, tag) { + w.emitHMACRejected(ctx, j.ID, payload, errors.New("hmac mismatch")) + _ = queue.Fail(ctx, w.pool, j.ID, "hmac_rejected: signature does not match payload") + return + } + + // The user who invoked the action rides on the queue row's correlation + // chain; the audit actor is the request's requester/reviewer context. + // We attribute the system action to the request's host for traceability; + // the HTTP layer already emitted the intent. Actor is uuid.Nil here + // (system), with the request id in detail. + switch payload.Action { + case RemediationActionExecute: + w.processExecute(ctx, j, payload) + case RemediationActionRollback: + w.processRollback(ctx, j, payload) + default: + _ = queue.Fail(ctx, w.pool, j.ID, "unknown remediation action: "+payload.Action) + } +} + +// processExecute drives approved -> executing -> executed|failed. +func (w *RemediationWorker) processExecute(ctx context.Context, j *queue.Job, p RemediationPayload) { + // Guard + transition approved -> executing (row-locked). A duplicate + // enqueue or a request not in 'approved' fails here without touching the + // host. + rq, err := w.svc.MarkExecuting(ctx, p.RequestID) + if err != nil { + if errors.Is(err, remediation.ErrWrongState) || errors.Is(err, remediation.ErrNotFound) { + _ = queue.Fail(ctx, w.pool, j.ID, "execute precondition: "+err.Error()) + return + } + slog.WarnContext(ctx, "remediation MarkExecuting failed", + slog.String("request_id", p.RequestID.String()), + slog.String("error", err.Error())) + _ = queue.Fail(ctx, w.pool, j.ID, "execute mark: "+err.Error()) + return + } + + // Apply the rule on the host (Capture/Apply/Validate/Commit). The + // executor owns the per-host concurrency guard. + result, remErr := w.executor.Remediate(ctx, p.HostID, p.RuleID) + if remErr != nil { + // Host-side failure: record an empty journal + transition to failed. + w.finishExecute(ctx, j, rq, p, nil, false) + slog.WarnContext(ctx, "remediation execute failed on host", + slog.String("request_id", p.RequestID.String()), + slog.String("host_id", p.HostID.String()), + slog.String("error", remErr.Error())) + return + } + + txns := mapExecTxns(result.Transactions) + committed := anyCommitted(txns) + w.finishExecute(ctx, j, rq, p, txns, committed) +} + +// finishExecute writes the journal, transitions to executed|failed, flips the +// rule to pass on a committed execute, publishes + audits, completes the job. +func (w *RemediationWorker) finishExecute(ctx context.Context, j *queue.Job, + rq remediation.Request, p RemediationPayload, txns []remediation.ExecTxn, committed bool) { + + final, err := w.svc.RecordExecution(ctx, p.RequestID, p.RuleID, txns) + if err != nil { + slog.WarnContext(ctx, "remediation RecordExecution failed", + slog.String("request_id", p.RequestID.String()), + slog.String("error", err.Error())) + _ = queue.Fail(ctx, w.pool, j.ID, "record execution: "+err.Error()) + return + } + + // On a committed execute, flip THIS one rule to pass in host_rule_state so + // the compliance score moves. Kensa's Validate ran before Commit, so the + // rule passes now — no full re-scan needed. The transaction-log Writer is + // scan_id-idempotent; we key on the request id as a synthetic scan id. + if committed { + if err := w.flipRuleToPass(ctx, p); err != nil { + slog.WarnContext(ctx, "remediation host_rule_state flip failed", + slog.String("request_id", p.RequestID.String()), + slog.String("rule_id", p.RuleID), + slog.String("error", err.Error())) + // Non-fatal: the request is recorded executed; the next scan will + // reconcile host_rule_state. We do not fail the job for this. + } + } + + w.svc.EmitExecuted(ctx, final, uuid.Nil, committed) + w.publishCompleted(ctx, eventbus.RemediationCompleted{ + RequestID: final.ID, + HostID: final.HostID, + RuleID: final.RuleID, + Action: RemediationActionExecute, + FinalStatus: string(final.Status), + RuleFlipped: committed, + CompletedAt: w.clock().UTC(), + }) + + if err := queue.Complete(ctx, w.pool, j.ID); err != nil { + slog.WarnContext(ctx, "remediation queue.Complete failed", + slog.String("job_id", j.ID.String()), + slog.String("error", err.Error())) + } + _ = rq // rq (pre-transition) retained for symmetry / future detail use +} + +// processRollback drives executed -> rolled_back. +func (w *RemediationWorker) processRollback(ctx context.Context, j *queue.Job, p RemediationPayload) { + // The request must be 'executed'. We do not transition up-front (no + // 'rolling_back' state); MarkRolledBack at the end guards executed -> + // rolled_back. Validate the precondition by reading the request. + rq, err := w.svc.Get(ctx, p.RequestID) + if err != nil { + _ = queue.Fail(ctx, w.pool, j.ID, "rollback precondition: "+err.Error()) + return + } + if rq.Status != remediation.StatusExecuted { + _ = queue.Fail(ctx, w.pool, j.ID, "rollback precondition: request not in executed state") + return + } + + // Resolve the rollback handle: the payload's txn id, or the first + // committed transaction recorded for the request. + txnID := p.TxnID + if txnID == uuid.Nil { + resolved, ok, ferr := w.svc.FirstCommittedTxn(ctx, p.RequestID) + if ferr != nil || !ok { + _ = queue.Fail(ctx, w.pool, j.ID, "rollback: no committed transaction to revert") + return + } + txnID = resolved + } + + res, rbErr := w.executor.Rollback(ctx, p.HostID, txnID) + status := "failed" + if rbErr == nil && res != nil { + status = res.Status + } + + // Only a clean rollback flips the lifecycle to rolled_back. + if rbErr == nil && res != nil && res.Status == "rolled_back" { + final, terr := w.svc.MarkRolledBack(ctx, p.RequestID) + if terr != nil { + slog.WarnContext(ctx, "remediation MarkRolledBack failed", + slog.String("request_id", p.RequestID.String()), + slog.String("error", terr.Error())) + _ = queue.Fail(ctx, w.pool, j.ID, "rollback mark: "+terr.Error()) + return + } + w.svc.EmitRolledBack(ctx, final, uuid.Nil, status) + w.publishCompleted(ctx, eventbus.RemediationCompleted{ + RequestID: final.ID, + HostID: final.HostID, + RuleID: final.RuleID, + Action: RemediationActionRollback, + FinalStatus: string(final.Status), + CompletedAt: w.clock().UTC(), + }) + if err := queue.Complete(ctx, w.pool, j.ID); err != nil { + slog.WarnContext(ctx, "remediation rollback queue.Complete failed", + slog.String("job_id", j.ID.String()), + slog.String("error", err.Error())) + } + return + } + + // Rollback did not cleanly restore: leave the request 'executed', audit + // the outcome, fail the job so the operator sees it did not revert. + w.svc.EmitRolledBack(ctx, rq, uuid.Nil, status) + detail := status + if rbErr != nil { + detail = rbErr.Error() + } + _ = queue.Fail(ctx, w.pool, j.ID, "rollback did not restore: "+detail) +} + +// flipRuleToPass writes a single-rule transaction-log batch marking p.RuleID +// pass on the host. Keyed on the request id as a synthetic scan id so the +// Writer's scan_id idempotency makes a re-delivered job a no-op. +func (w *RemediationWorker) flipRuleToPass(ctx context.Context, p RemediationPayload) error { + return w.writer.Apply(ctx, transactionlog.ApplyBatch{ + ScanID: p.RequestID, // synthetic scan id == request id (idempotent) + HostID: p.HostID, + Results: []transactionlog.Result{{ + RuleID: p.RuleID, + Status: transactionlog.StatusPass, + Evidence: mustEvidence(map[string]any{ + "source": "remediation", + "request_id": p.RequestID.String(), + "note": "rule passed post-remediation (Kensa Validate before Commit)", + }), + }}, + }) +} + +// publishCompleted publishes remediation.completed when a bus is wired. +func (w *RemediationWorker) publishCompleted(ctx context.Context, ev eventbus.RemediationCompleted) { + if w.bus == nil { + return + } + w.bus.Publish(ctx, ev) +} + +// emitHMACRejected fires scheduler.job.hmac_rejected for a rejected +// remediation payload (the same audit code the scan path reuses on verify). +func (w *RemediationWorker) emitHMACRejected(ctx context.Context, jobID uuid.UUID, p RemediationPayload, cause error) { + failure := "hmac_mismatch" + if errors.Is(cause, errRemMissingHMAC) { + failure = "payload_missing_hmac" + } + detail, _ := json.Marshal(map[string]string{ + "job_id": jobID.String(), + "request_id": p.RequestID.String(), + "host_id": p.HostID.String(), + "failure": failure, + "job_type": RemediationJobType, + }) + w.emit(ctx, audit.SchedulerJobHmacRejected, audit.Event{ + ActorType: "system", + Detail: detail, + }) +} + +// mapExecTxns converts kensa transaction outcomes into the neutral remediation +// journal shape (so internal/remediation never imports internal/kensa). +func mapExecTxns(in []kensa.RemediationTxn) []remediation.ExecTxn { + out := make([]remediation.ExecTxn, 0, len(in)) + for _, t := range in { + out = append(out, remediation.ExecTxn{ + TxnID: t.TxnID, + Status: t.Status, + Evidence: t.Evidence, + Err: t.Err, + }) + } + return out +} + +func anyCommitted(txns []remediation.ExecTxn) bool { + for _, t := range txns { + if t.Committed() { + return true + } + } + return false +} + +func mustEvidence(v map[string]any) []byte { + b, _ := json.Marshal(v) + return b +} diff --git a/internal/worker/remediation_worker_test.go b/internal/worker/remediation_worker_test.go new file mode 100644 index 000000000..621fbbaee --- /dev/null +++ b/internal/worker/remediation_worker_test.go @@ -0,0 +1,318 @@ +// @spec api-remediation +// +// Worker execution-path AC (DSN-gated): +// +// AC-07 TestRemediationWorker_Execute_Committed_FlipsRuleToPass +// AC-07 TestRemediationWorker_Execute_Errored_Failed_NoFlip +// AC-07 TestRemediationWorker_HMACMismatch_DeadLettered_NoExecutorCall + +package worker + +import ( + "context" + "encoding/json" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Hanalyx/openwatch/internal/audit" + "github.com/Hanalyx/openwatch/internal/correlation" + "github.com/Hanalyx/openwatch/internal/kensa" + "github.com/Hanalyx/openwatch/internal/queue" + "github.com/Hanalyx/openwatch/internal/remediation" + "github.com/Hanalyx/openwatch/internal/scheduler" + "github.com/Hanalyx/openwatch/internal/transactionlog" +) + +// seedApprovedRequest creates a remediation request via the service and +// approves it (requester != reviewer), returning the approved request id. +func seedApprovedRequest(t *testing.T, pool *pgxpool.Pool, svc *remediation.Service, + hostID uuid.UUID, ruleID string) uuid.UUID { + t.Helper() + requester := seedUniqueUser(t, pool) + reviewer := seedUniqueUser(t, pool) + ctx := context.Background() + rq, err := svc.Request(ctx, hostID, ruleID, nil, requester) + if err != nil { + t.Fatalf("seed request: %v", err) + } + if _, err := svc.Approve(ctx, rq.ID, reviewer, "ok"); err != nil { + t.Fatalf("approve request: %v", err) + } + return rq.ID +} + +// remUserSeq guarantees unique usernames/emails across seedUniqueUser calls — +// shared seedUser builds its name from a UUIDv7 prefix that collides when two +// users are seeded in the same millisecond. +var remUserSeq atomic.Int64 + +func seedUniqueUser(t *testing.T, pool *pgxpool.Pool) uuid.UUID { + t.Helper() + id, _ := uuid.NewV7() + n := remUserSeq.Add(1) + uname := fmt.Sprintf("rem-user-%d-%s", n, id.String()) + _, err := pool.Exec(context.Background(), + `INSERT INTO users (id, username, email, password_hash) + VALUES ($1, $2, $3, $4)`, + id, uname, uname+"@example.com", "argon2id$dummy") // pragma: allowlist secret + if err != nil { + t.Fatalf("seed user: %v", err) + } + return id +} + +// enqueueRemediationJob signs + enqueues an execute remediation job. +func enqueueRemediationJob(t *testing.T, pool *pgxpool.Pool, key []byte, + requestID, hostID uuid.UUID, ruleID string) uuid.UUID { + t.Helper() + body := MarshalRemediationJob(key, RemediationPayload{ + RequestID: requestID, + HostID: hostID, + RuleID: ruleID, + Action: RemediationActionExecute, + }) + ctx := correlation.Set(context.Background(), correlation.Generate("test")) + id, err := queue.Enqueue(ctx, pool, RemediationJobType, body) + if err != nil { + t.Fatalf("enqueue remediation: %v", err) + } + return id +} + +func requestStatus(t *testing.T, pool *pgxpool.Pool, id uuid.UUID) string { + t.Helper() + var s string + if err := pool.QueryRow(context.Background(), + `SELECT status FROM remediation_requests WHERE id = $1`, id).Scan(&s); err != nil { + t.Fatalf("read request status: %v", err) + } + return s +} + +func ruleStateStatus(t *testing.T, pool *pgxpool.Pool, hostID uuid.UUID, ruleID string) (string, bool) { + t.Helper() + var s string + err := pool.QueryRow(context.Background(), + `SELECT current_status FROM host_rule_state WHERE host_id = $1 AND rule_id = $2`, + hostID, ruleID).Scan(&s) + if err != nil { + return "", false + } + return s, true +} + +func journalCount(t *testing.T, pool *pgxpool.Pool, requestID uuid.UUID) int { + t.Helper() + var n int + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM remediation_transactions WHERE request_id = $1`, requestID).Scan(&n); err != nil { + t.Fatalf("count journal: %v", err) + } + return n +} + +// fakeRemediate returns a RemediateFunc producing one transaction with the +// given status, and counts invocations. +func fakeRemediate(status string, calls *atomic.Int64) kensa.RemediateFunc { + return func(ctx context.Context, hostID uuid.UUID, ruleID string) (*kensa.RemediationRunResult, kensa.FailureReason, error) { + calls.Add(1) + txnID, _ := uuid.NewV7() + ev, _ := json.Marshal(map[string]string{"phase": status}) + return &kensa.RemediationRunResult{ + HostID: hostID, + RuleID: ruleID, + StartedAt: time.Now().UTC(), + CompletedAt: time.Now().UTC(), + Transactions: []kensa.RemediationTxn{{ + TxnID: txnID, + Status: status, + Evidence: ev, + }}, + }, "", nil + } +} + +func noopRollback() kensa.RollbackFunc { + return func(ctx context.Context, hostID uuid.UUID, txnID uuid.UUID) (*kensa.RollbackRunResult, kensa.FailureReason, error) { + return &kensa.RollbackRunResult{Status: "rolled_back"}, "", nil + } +} + +// recordingSvc wires a remediation.Service whose audit emit records into rec +// (so EmitExecuted / EmitRolledBack are observable). The named-type signatures +// match, so a thin adapter closure bridges worker.EmitFunc -> the service's. +func recordingSvc(pool *pgxpool.Pool, rec *emitRecorder) *remediation.Service { + emit := rec.Emit() + return remediation.NewService(pool, func(ctx context.Context, code audit.Code, ev audit.Event) { + emit(ctx, code, ev) + }) +} + +func remediationKey(t *testing.T) []byte { + t.Helper() + key, err := scheduler.DeriveQueueKey([]byte("test-dek-32-bytes-remediation-aa")) + if err != nil { + t.Fatalf("derive key: %v", err) + } + return key +} + +// @ac AC-07 +// A committed execute drives approved -> executing -> executed, writes one +// journal row (phase_result=committed), and flips the rule to pass in +// host_rule_state so the compliance score moves. +func TestRemediationWorker_Execute_Committed_FlipsRuleToPass(t *testing.T) { + t.Run("api-remediation/AC-07", func(t *testing.T) { + pool := freshPool(t) + user := seedUser(t, pool) + hostID := seedHost(t, pool, user) + const ruleID = "sshd-permit-root-no" + + rec := &emitRecorder{} + svc := recordingSvc(pool, rec) + reqID := seedApprovedRequest(t, pool, svc, hostID, ruleID) + + var calls atomic.Int64 + exec := kensa.NewExecutor(stubBridge{plain: []byte("x")}, rec.executorEmit()). + WithRemediateFunc(fakeRemediate("committed", &calls), noopRollback()) + writer := transactionlog.NewWriter(pool, rec.writerEmit()) + key := remediationKey(t) + + rw := NewRemediationWorker(RemediationConfig{ + Pool: pool, + Executor: exec, + Service: svc, + Writer: writer, + QueueKey: key, + Emit: rec.Emit(), + }) + + jobID := enqueueRemediationJob(t, pool, key, reqID, hostID, ruleID) + job, jobCtx, err := queue.Dequeue(context.Background(), pool) + if err != nil { + t.Fatalf("dequeue: %v", err) + } + rw.ProcessJob(jobCtx, job) + + if calls.Load() != 1 { + t.Errorf("remediate calls = %d, want 1", calls.Load()) + } + if st := requestStatus(t, pool, reqID); st != "executed" { + t.Errorf("request status = %q, want executed", st) + } + if n := journalCount(t, pool, reqID); n != 1 { + t.Errorf("journal rows = %d, want 1", n) + } + if st, ok := ruleStateStatus(t, pool, hostID, ruleID); !ok || st != "pass" { + t.Errorf("host_rule_state = (%q, %v), want (pass, true)", st, ok) + } + if js := jobStatus(t, pool, jobID); js != queue.StatusCompleted { + t.Errorf("job status = %q, want completed", js) + } + if rec.Count(audit.RemediationExecuted) != 1 { + t.Errorf("remediation.executed audits = %d, want 1", rec.Count(audit.RemediationExecuted)) + } + }) +} + +// @ac AC-07 +// An errored transaction transitions the request to failed and writes NO flip +// (host_rule_state untouched for the rule). +func TestRemediationWorker_Execute_Errored_Failed_NoFlip(t *testing.T) { + t.Run("api-remediation/AC-07", func(t *testing.T) { + pool := freshPool(t) + user := seedUser(t, pool) + hostID := seedHost(t, pool, user) + const ruleID = "auditd-enabled" + + rec := &emitRecorder{} + svc := recordingSvc(pool, rec) + reqID := seedApprovedRequest(t, pool, svc, hostID, ruleID) + + var calls atomic.Int64 + exec := kensa.NewExecutor(stubBridge{plain: []byte("x")}, rec.executorEmit()). + WithRemediateFunc(fakeRemediate("errored", &calls), noopRollback()) + writer := transactionlog.NewWriter(pool, rec.writerEmit()) + key := remediationKey(t) + + rw := NewRemediationWorker(RemediationConfig{ + Pool: pool, Executor: exec, Service: svc, Writer: writer, + QueueKey: key, Emit: rec.Emit(), + }) + + enqueueRemediationJob(t, pool, key, reqID, hostID, ruleID) + job, jobCtx, err := queue.Dequeue(context.Background(), pool) + if err != nil { + t.Fatalf("dequeue: %v", err) + } + rw.ProcessJob(jobCtx, job) + + if st := requestStatus(t, pool, reqID); st != "failed" { + t.Errorf("request status = %q, want failed", st) + } + if _, ok := ruleStateStatus(t, pool, hostID, ruleID); ok { + t.Errorf("host_rule_state row exists for rule; want none (no flip on errored)") + } + }) +} + +// @ac AC-07 +// A job whose payload HMAC does not verify is dead-lettered (queue.Fail) with +// scheduler.job.hmac_rejected and the executor is never invoked. +func TestRemediationWorker_HMACMismatch_DeadLettered_NoExecutorCall(t *testing.T) { + t.Run("api-remediation/AC-07", func(t *testing.T) { + pool := freshPool(t) + user := seedUser(t, pool) + hostID := seedHost(t, pool, user) + const ruleID = "rule-z" + + rec := &emitRecorder{} + svc := recordingSvc(pool, rec) + reqID := seedApprovedRequest(t, pool, svc, hostID, ruleID) + + var calls atomic.Int64 + exec := kensa.NewExecutor(stubBridge{plain: []byte("x")}, rec.executorEmit()). + WithRemediateFunc(fakeRemediate("committed", &calls), noopRollback()) + writer := transactionlog.NewWriter(pool, rec.writerEmit()) + goodKey := remediationKey(t) + + // Sign with a DIFFERENT key so the worker's verify fails. + badKey, _ := scheduler.DeriveQueueKey([]byte("WRONG-dek-32-bytes-remediation-b")) + body := MarshalRemediationJob(badKey, RemediationPayload{ + RequestID: reqID, HostID: hostID, RuleID: ruleID, Action: RemediationActionExecute, + }) + ctx := correlation.Set(context.Background(), correlation.Generate("test")) + jobID, err := queue.Enqueue(ctx, pool, RemediationJobType, body) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + + rw := NewRemediationWorker(RemediationConfig{ + Pool: pool, Executor: exec, Service: svc, Writer: writer, + QueueKey: goodKey, Emit: rec.Emit(), + }) + job, jobCtx, derr := queue.Dequeue(context.Background(), pool) + if derr != nil { + t.Fatalf("dequeue: %v", derr) + } + rw.ProcessJob(jobCtx, job) + + if calls.Load() != 0 { + t.Errorf("remediate calls = %d, want 0 (HMAC rejected before executor)", calls.Load()) + } + if js := jobStatus(t, pool, jobID); js != queue.StatusFailed { + t.Errorf("job status = %q, want failed", js) + } + if st := requestStatus(t, pool, reqID); st != "approved" { + t.Errorf("request status = %q, want approved (untouched)", st) + } + if rec.Count(audit.SchedulerJobHmacRejected) != 1 { + t.Errorf("hmac_rejected audits = %d, want 1", rec.Count(audit.SchedulerJobHmacRejected)) + } + }) +} diff --git a/internal/worker/scan_runs_logbook_test.go b/internal/worker/scan_runs_logbook_test.go index 331e6b180..1c9dcf913 100644 --- a/internal/worker/scan_runs_logbook_test.go +++ b/internal/worker/scan_runs_logbook_test.go @@ -62,9 +62,13 @@ func TestEveryFailPath_PairsWithMarkRunFailed(t *testing.T) { if !strings.Contains(line, "queue.Fail(") { continue } - // The wrong-job-type fast-fail is exempt (not a scan). + // The wrong-job-type fast-fail is exempt (not a scan). The + // remediation dispatch branch is likewise exempt — a + // remediation job owns a remediation_requests lifecycle, not a + // scan_runs row, so it must NOT write the scan logbook. window := strings.Join(lines[max(0, i-6):min(len(lines), i+7)], "\n") - if strings.Contains(window, "unsupported job_type") { + if strings.Contains(window, "unsupported job_type") || + strings.Contains(window, "remediation processor not registered") { continue } if !strings.Contains(window, "markRunFailed") && !strings.Contains(window, "MarkFailed") { diff --git a/internal/worker/scan_worker.go b/internal/worker/scan_worker.go index 6e34c6bbe..c357e7253 100644 --- a/internal/worker/scan_worker.go +++ b/internal/worker/scan_worker.go @@ -80,6 +80,7 @@ type ScanWorker struct { queueKey []byte bus *eventbus.Bus // nil = no scan.completed publication (dedicated worker process) sched *scheduler.Service // nil = no post-scan schedule update (legacy tests) + remProc *RemediationWorker // nil = "remediation" jobs fail (legacy tests) pollInterval time.Duration @@ -129,6 +130,12 @@ type Config struct { // schedule on completion. Sched *scheduler.Service + // RemediationProcessor, when non-nil, handles "remediation" job_type + // rows this worker claims. queue.Dequeue is not type-filtered, so the + // dedicated worker subcommand routes remediation jobs to it rather than + // dead-lettering them. Spec api-remediation. + RemediationProcessor *RemediationWorker + // clock allows tests to inject a controllable time source. // Production passes time.Now. Clock func() time.Time @@ -157,6 +164,7 @@ func NewScanWorker(cfg Config) *ScanWorker { queueKey: cfg.QueueKey, bus: cfg.Bus, sched: cfg.Sched, + remProc: cfg.RemediationProcessor, pollInterval: cfg.PollInterval, clock: cfg.Clock, emit: cfg.Emit, @@ -241,8 +249,18 @@ func (w *ScanWorker) ProcessJob(ctx context.Context, j *queue.Job) { } }() - // Non-scan jobs land on the wrong worker. Fail fast — no executor - // invocation, no advisory lock. + // Dispatch by job type: this one worker drains the queue (queue.Dequeue + // is not type-filtered) and routes by JobType. Remediation jobs go to the + // remediation processor when wired; anything else that isn't a scan fails + // fast — no executor invocation, no advisory lock. + if j.JobType == RemediationJobType { + if w.remProc == nil { + _ = queue.Fail(ctx, w.pool, j.ID, "remediation processor not registered on this worker") + return + } + w.remProc.ProcessJob(ctx, j) + return + } if j.JobType != ScanJobType { _ = queue.Fail(ctx, w.pool, j.ID, fmt.Sprintf("unsupported job_type %q (scan worker)", j.JobType)) return diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 4047f39e5..ebe670d9d 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -53,6 +53,7 @@ type Worker struct { wg sync.WaitGroup discovery HostDiscoveryRunner scanProc *ScanWorker + remProc *RemediationWorker concurrency int } @@ -102,6 +103,15 @@ func (w *Worker) WithScanProcessor(sw *ScanWorker) *Worker { return w } +// WithRemediationProcessor registers a RemediationWorker whose ProcessJob +// handles "remediation" jobs claimed by THIS worker's loop. Like scan jobs, +// queue.Dequeue is not type-filtered, so the in-process worker must route +// remediation jobs rather than fail them as unsupported. Spec api-remediation. +func (w *Worker) WithRemediationProcessor(rw *RemediationWorker) *Worker { + w.remProc = rw + return w +} + // Start kicks off w.concurrency drain loops on background goroutines. Returns // immediately. Safe to call once per Worker. Each loop claims jobs // independently (SKIP LOCKED), so up to w.concurrency jobs run at once. @@ -187,6 +197,12 @@ func (w *Worker) process(ctx context.Context, j *queue.Job) { return } w.scanProc.ProcessJob(ctx, j) + case RemediationJobType: + if w.remProc == nil { + _ = queue.Fail(ctx, w.pool, j.ID, "remediation processor not registered on this worker") + return + } + w.remProc.ProcessJob(ctx, j) default: _ = queue.Fail(ctx, w.pool, j.ID, "unsupported job_type for Stage 0 worker: "+j.JobType) } diff --git a/licensing/features.yaml b/licensing/features.yaml index f8eb943ae..7cf928001 100644 --- a/licensing/features.yaml +++ b/licensing/features.yaml @@ -46,7 +46,7 @@ features: - id: remediation_execution tier: openwatch_plus - description: Apply Kensa remediation against hosts with dry-run, execute, and rollback + description: Bulk and automated remediation - apply many rules / fleet-wide and policy-driven auto-remediation (single-rule manual remediation is free core) introduced: "1.0.0" - id: structured_exceptions diff --git a/specs/api/remediation.spec.yaml b/specs/api/remediation.spec.yaml new file mode 100644 index 000000000..ae042ff21 --- /dev/null +++ b/specs/api/remediation.spec.yaml @@ -0,0 +1,150 @@ +spec: + id: api-remediation + title: Remediation governance - request/approve lifecycle, projected lift, and queued single-rule execute/rollback (OpenWatch Core, free) + version: "1.1.0" + status: approved + tier: 1 + + context: + system: openwatch-go + feature: > + The free (AGPLv3 core) Phase 7 remediation surface: a see-govern-and-act + loop over failing rules. Operators view what is remediable and the + projected compliance-score lift, request a fix, route it through + approval, then EXECUTE the approved single-rule fix (Tier A free core): + an HMAC-signed remediation job is enqueued and a worker applies the rule + on the host via Kensa (Capture/Apply/Validate/Commit), records the + per-step journal, and flips the rule to pass in host_rule_state on a + committed run. A previously executed request can be ROLLED BACK. execute + and rollback are FREE (remediation:execute / remediation:rollback, no + license). Only :dry-run remains unbuilt (501). + 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: read (remediation:read), request + (remediation:request), approve/reject (remediation:approve), and the act + verbs execute (remediation:execute) and rollback (remediation:rollback) + are ALL free core. execute/rollback enforce their dangerous-but-ungated + permission (no license feature); a caller lacking the permission is 403. + Only :dry-run remains unbuilt and returns 501 (no license gate). + + Execute/rollback are asynchronous and mirror the scan job path: the + handler guards the request's state, enqueues an HMAC-signed remediation + job on the PostgreSQL job queue (signed with the same queue key as scans, + domain-separated), and returns 202 Accepted. One worker drains the queue + and dispatches by job_type ("scan" vs "remediation"). The worker + transitions approved -> executing -> executed | failed (execute) or + executed -> rolled_back (rollback), writes the remediation_transactions + journal, and on a committed execute flips THAT one rule to pass in + host_rule_state via the transaction-log writer (Kensa's Validate runs + before Commit, so the rule passes - no full re-scan), so the compliance + score moves. It then emits remediation.executed / remediation.rolled_back + and publishes remediation.completed on the event bus. + + Lifecycle: pending_approval -> approved | rejected -> executing -> + executed | failed -> rolled_back. 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 execution service methods - MarkExecuting / RecordExecution / MarkRolledBack + the remediation_transactions journal + - The free HTTP endpoints (list, get, request, approve, reject, steps, audit-events) + - The queued single-rule execute/rollback (Tier A free core) - HMAC-signed remediation job, worker dispatch by job_type, host_rule_state flip-to-pass on a committed run + excludes: + - :dry-run (returns 501, not yet built; no longer license-gated) + - The fleet auto-remediation policy engine (remediation_auto, licensed) + - Bulk / grouped remediation and Kensa rule ordering (blocked on a Kensa ratification) + - Full re-scan after a remediation (only the single remediated rule is flipped to pass) + + 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: 'Execute/rollback are FREE core (Tier A), NOT license-gated. :execute requires remediation:execute; the request must be in approved state (409 otherwise); the handler enqueues an HMAC-signed remediation job (signed with the same queue key as scans, domain-separated) and returns 202. :rollback requires remediation:rollback; the request must be in executed state (409 otherwise); it enqueues a rollback job and returns 202. A caller lacking the permission is 403 (RBAC). :dry-run requires remediation:execute and returns 501 (unbuilt), no license gate. No remediation act endpoint returns 402.' + type: security + enforcement: error + - id: C-08 + description: 'The worker drains ONE queue and dispatches by job_type. A remediation job HMAC-verifies its payload (request_id, host_id, rule_id, action, optional txn_id) before any host-mutating side effect; a missing/mismatched tag dead-letters the job (queue.Fail) and emits scheduler.job.hmac_rejected, with no executor call. The worker transitions approved -> executing (MarkExecuting, row-locked) -> executed | failed (RecordExecution), writes the remediation_transactions journal, and on a committed transaction flips that one rule to pass in host_rule_state via the transaction-log writer (idempotent on the request id as synthetic scan id). Rollback transitions executed -> rolled_back only on a clean restore. The worker emits remediation.executed / remediation.rolled_back and publishes remediation.completed on the event bus.' + type: technical + 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: 'Execute/rollback are FREE core: a caller WITH remediation:execute executing an approved request gets 202 and a remediation job is enqueued; a caller LACKING remediation:execute is 403; executing a non-approved request is 409; rolling back (remediation:rollback) a non-executed request is 409. No remediation act endpoint returns 402. :dry-run returns 501. request/approve/reject and the GET reads return their normal status codes.' + priority: critical + references_constraints: [C-06] + - id: AC-07 + description: 'Worker execution path: a queued remediation job with a valid HMAC drives an approved request approved -> executing -> executed when the executor returns a committed transaction, writes a remediation_transactions journal row (kensa_txn_id, phase_result=committed), and flips the rule to pass in host_rule_state (the compliance score moves). A committed run that the executor reports as errored, or a host-side failure, transitions the request to failed and writes no flip. A job whose payload HMAC does not verify is dead-lettered (queue.Fail) with scheduler.job.hmac_rejected and no executor invocation.' + priority: critical + references_constraints: [C-08] diff --git a/specs/api/users.spec.yaml b/specs/api/users.spec.yaml index 162191c75..1f45b1890 100644 --- a/specs/api/users.spec.yaml +++ b/specs/api/users.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-users title: User CRUD + custom-role administration - version: "1.1.0" + version: "1.2.0" status: approved tier: 2 @@ -32,6 +32,8 @@ spec: - POST /users/{id}/roles:assign - POST /users/{id}/roles:unassign - POST /roles (custom role create) + - "POST /users/{id}:reset-password (admin reset, own or another's)" + - "POST /users/{id}:disable and :enable (account disable)" excludes: - "PUT /users/{id} (email patch) — deferred to A.5" - "DELETE / PUT /roles (built-ins can't be touched, custom-role delete deferred)" @@ -58,6 +60,18 @@ spec: description: POST /users/{id}/roles:assign MUST NOT let a caller grant a role more privileged than themselves. The assigned role's permission set MUST be a subset of the assigning caller's own permissions; otherwise the request is denied 403 and no role is assigned. This prevents privilege escalation via role assignment (mirrors the api-tokens C-03 rule; both use auth.RoleGrantsWithin). type: security enforcement: error + - id: C-06 + description: 'POST /users/{id}:reset-password MUST require admin:user_manage and set the target''s password WITHOUT requiring the current password (administrator authority). The new password MUST run through the role-aware policy + breach screen (rejected => 400), and on success the target''s active sessions MUST be revoked so they re-authenticate. The target MAY be the caller themselves (admin reset of own password).' + type: security + enforcement: error + - id: C-07 + description: 'POST /users/{id}:disable and :enable MUST require admin:user_manage. Disable sets users.disabled_at and MUST revoke the target''s active sessions; a disabled user MUST be unable to authenticate (the login path rejects them with a generic invalid-credentials response, no account-state enumeration, while the audit records reason=account_disabled). Enable clears disabled_at. An admin MUST NOT disable their own account (409 users.cannot_disable_self). Cookie sessions are cut off immediately via revocation + the login block; full Bearer-JWT / API-token disable enforcement is a documented hardening follow-up.' + type: security + enforcement: error + - id: C-08 + description: 'Each admin user-management mutation MUST emit its audit code (admin.user.password_reset | admin.user.disabled | admin.user.enabled) carrying detail.target_user_id; an unknown or soft-deleted target MUST return 404 users.not_found.' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -108,3 +122,27 @@ spec: description: POST /users/{id}/roles:assign applies the auth.RoleGrantsWithin subset check before assigning — assigning a role within the caller's grant returns 204, and a role whose permissions exceed the caller's is denied 403 with no row written. (Only the admin role currently holds role:assign, and admin's grant covers every role, so the exceeds-grant denial is asserted directly on auth.RoleGrantsWithin; it becomes reachable if a non-admin role is ever granted role:assign.) priority: critical references_constraints: [C-05] + - id: AC-14 + description: 'POST /users/{id}:reset-password as an admin on another user returns 204 with no current password; the target''s active sessions are revoked and the new password authenticates at login. A password that fails policy (too short/long/breached) returns 400 validation.password_policy and leaves the old password intact. A caller lacking admin:user_manage is 403.' + priority: critical + references_constraints: [C-06] + - id: AC-15 + description: 'POST /users/{id}:reset-password targeting the caller''s OWN id returns 204 (an admin resets their own password without supplying the current one) and emits admin.user.password_reset with detail.self=true.' + priority: high + references_constraints: [C-06] + - id: AC-16 + description: 'POST /users/{id}:disable as an admin returns 200 with disabled_at set; the disabled user can no longer log in (login returns the generic auth.invalid_credentials, audit reason=account_disabled) and their existing sessions are revoked. Emits admin.user.disabled with detail.target_user_id.' + priority: critical + references_constraints: [C-07] + - id: AC-17 + description: 'POST /users/{id}:enable clears disabled_at (200, disabled_at null) and the user can authenticate again; emits admin.user.enabled.' + priority: high + references_constraints: [C-07] + - id: AC-18 + description: 'An admin calling POST /users/{id}:disable with their OWN id is denied 409 users.cannot_disable_self and the account is unchanged (still able to authenticate).' + priority: critical + references_constraints: [C-07] + - id: AC-19 + description: 'reset-password, :disable and :enable each return 404 users.not_found for an unknown/soft-deleted user, and each requires admin:user_manage (a caller without it is 403 before any state change).' + priority: high + references_constraints: [C-06, C-07, C-08] diff --git a/specs/frontend/remediation-tab.spec.yaml b/specs/frontend/remediation-tab.spec.yaml new file mode 100644 index 000000000..951068263 --- /dev/null +++ b/specs/frontend/remediation-tab.spec.yaml @@ -0,0 +1,110 @@ +spec: + id: frontend-remediation-tab + title: Host detail Remediation tab + per-rule request/apply affordance + version: "1.1.0" + status: approved + tier: 2 + + context: + system: openwatch-go + feature: The remediation governance + single-rule apply frontend - a per-failing-rule Request-remediation affordance on the Compliance tab plus a Remediation tab that drives the request -> approve | reject -> execute | rollback lifecycle and renders BULK and AUTOMATED remediation as an OpenWatch+ upsell. + description: > + The frontend half of the remediation 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) + POST /api/v1/remediation/requests/{rid}:execute - apply approved fix (202) + POST /api/v1/remediation/requests/{rid}:rollback - revert executed fix (202) + + Per-rule MANUAL execute + rollback are now FREE core (no license). + :execute requires remediation:execute and returns 202 (queued), or + 409 if the request is not in 'approved' state. :rollback requires + remediation:rollback, returns 202, or 409 if not 'executed'. The + remaining OpenWatch+ paywall is BULK and AUTOMATED remediation + (apply many rules / fleet-wide, scheduled auto-remediation), + rendered as a DISABLED upsell never wired to any endpoint. + + The lifecycle the UI drives is + pending_approval -> approved -> executing -> executed (with + rollback to rolled_back, and failed as a terminal error state). + useHostRemediations mirrors useHostExceptions: it fetches the + host's requests under the ['host', hostId, 'remediations'] key + (riding the scan.completed SSE invalidation), derives the open set + used to suppress a duplicate per-rule request, and polls while any + request is 'executing' so the row advances without a manual + refresh. + + 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, executing poll) + - Per-rule Request-remediation affordance on the Compliance tab, gated on remediation:request + - RemediationTab panel on host detail with approve/reject gating and the atomic-model explainer + - Per-rule Fix (:execute) gated on remediation:execute||isAdmin and Roll back (:rollback) gated on remediation:rollback||isAdmin + - Lifecycle status rendering (executing "Applying...", executed "Fixed", rolled_back, failed) + - The OpenWatch+ upsell for BULK and AUTOMATED remediation (disabled, never calls any endpoint) + excludes: + - Calling :dry-run (OpenWatch+ licensed track) + - Bulk / fleet-wide apply and scheduled auto-remediation (OpenWatch+, upsell only) + - The transaction journal (steps) view + - 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 (|| isAdmin) gets Approve / Reject (POST :approve / :reject, invalidate on success, 409 inline); without it, "Awaiting approval". BULK and AUTOMATED remediation render as a DISABLED OpenWatch+ upsell that is never wired to any endpoint' + type: security + enforcement: error + - id: C-04 + description: 'Per-rule MANUAL apply is free core. An approved row shows a Fix button gated on hasPermission("remediation:execute") || isAdmin that POSTs /api/v1/remediation/requests/{rid}:execute and invalidates ["host", hostId, "remediations"] on 202; a 409 surfaces an inline message via apiErrorMessage. An executed row shows a "Fixed" status and a Roll back button gated on hasPermission("remediation:rollback") || isAdmin that POSTs :rollback and invalidates on 202. An executing row shows a non-interactive "Applying..." status with no button; rolled_back and failed render terminal status (failed includes the reason when present). UI copy carries no em-dashes' + type: technical + 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, and renders the Capture/Apply/Validate/Commit explainer' + priority: high + references_constraints: [C-03] + - id: AC-04 + description: 'Source inspection: an approved row renders a Fix button gated on hasPermission("remediation:execute") || isAdmin (computed as hasPermission("admin")). On click it POSTs "/api/v1/remediation/requests/{rid}:execute" and invalidates ["host", hostId, "remediations"] on success; a 409 surfaces the inline "This request is not in an approvable state." message via apiErrorMessage' + priority: critical + references_constraints: [C-04] + - id: AC-05 + description: 'Source inspection: an executed row renders a "Fixed" status and a Roll back button gated on hasPermission("remediation:rollback") || isAdmin that POSTs "/api/v1/remediation/requests/{rid}:rollback" and invalidates ["host", hostId, "remediations"] on success' + priority: critical + references_constraints: [C-04] + - id: AC-06 + description: 'Source inspection: status rendering covers the lifecycle - an executing row shows a non-interactive "Applying..." status (no button), rolled_back shows "Rolled back", and failed shows "Failed" (with review_note reason when present). useHostRemediations sets a refetchInterval while any request status is "executing"' + priority: high + references_constraints: [C-04] + - id: AC-07 + description: 'Source inspection: the OpenWatch+ upsell now describes BULK and AUTOMATED remediation (RemediationUpsell, "Bulk and automated remediation (OpenWatch+)"), is DISABLED, and the single-rule "Execute on host (OpenWatch+)" upsell copy is gone. No em-dash characters appear in the RemediationTab copy' + priority: high + references_constraints: [C-03, C-04] diff --git a/specs/frontend/settings.spec.yaml b/specs/frontend/settings.spec.yaml index 2b6b9409c..39ef76f42 100644 --- a/specs/frontend/settings.spec.yaml +++ b/specs/frontend/settings.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-settings title: Settings — two-pane shell with 11 sub-pages - version: "1.9.0" + version: "1.10.0" status: draft tier: 2 @@ -59,7 +59,7 @@ spec: - "Preferences — theme/density/accent/landing/host-view/date-format/reduce-motion persisted to localStorage" - "SSH & credentials — list from GET /api/v1/credentials with Add (POST), Edit (in-place PATCH /credentials/{id}) and Delete (DELETE) modals; editing keeps the stored secret when the field is left blank (v1.9.0)" - "SSH keys section is a derived view of key-bearing credentials; Add/Edit/Delete a key operates on the parent credential" - - "Users & teams — list from GET /api/v1/users (admin-gated), with roles per member, Invite (POST /users) and Manage (role assign/unassign + soft-delete) modals (v1.4.0)" + - "Users & teams — list from GET /api/v1/users (admin-gated), with roles per member, Invite (POST /users) and Manage (role assign/unassign + soft-delete) modals (v1.4.0); Manage also offers admin password reset (POST /users/{id}:reset-password) and disable/enable (POST /users/{id}:disable|:enable), gated on admin:user_manage; disabled members show a Disabled status on the roster and in the modal (v1.10.0)" - "Audit log — filterable, cursor-paginated GET /api/v1/audit/events, audit:read gated, read-only (v1.3.0)" - "About — version from GET /api/v1/version + license state from GET /api/v1/license (v1.3.0)" - "Notifications — Slack/webhook channel CRUD + test over /api/v1/notifications/channels, notification:read gated, secrets write-only (v1.5.0)" @@ -137,7 +137,16 @@ spec: (role assign/unassign via /users/{id}/roles:assign|:unassign, soft-delete via DELETE /users/{id}) open modals; all four mutations invalidate the ['users'] query on success. Write - controls are gated on user:write (admin implies it). + controls are gated on user:write (admin implies it). v1.10.0 — + Manage also exposes admin password reset (POST + /users/{id}:reset-password), disable (POST /users/{id}:disable) + and enable (POST /users/{id}:enable). These three admin-authority + actions are gated on admin:user_manage (admin implies it) and + invalidate ['users'] on success. A 400 from reset-password + surfaces the server's policy reason inline; a 409 from disabling + your own account surfaces an inline message. Disabled members + (disabled_at non-null) show a Disabled status on the roster row + and in the modal. type: security enforcement: error - id: C-09 @@ -333,3 +342,18 @@ spec: description: "v1.8.0 source-inspection: SecurityPage's single-sign-on section is gated on admin:sso_provider and does OIDC provider CRUD keyed ['sso-providers'] — api.GET/POST '/api/v1/sso/providers', api.PUT/DELETE '/api/v1/sso/providers/{id}' — with the client secret write-only (edit form labels it 'leave blank to keep'); LoginPage fetches api.GET '/api/v1/sso/providers/enabled' and renders Sign in with X buttons that navigate to '/api/v1/auth/sso/${providerId}/login'." priority: high references_constraints: [C-16] + + - id: AC-27 + description: "v1.10.0 source-inspection: ManageUserModal computes canManage = hasPermission('admin:user_manage') || isAdmin and gates the admin-authority actions on it. Reset password POSTs '/api/v1/users/{id}:reset-password' with { new_password }, reads a rejected-password reason via apiErrorMessage so a 400 policy failure is surfaced inline, and invalidates the ['users'] query on success." + priority: high + references_constraints: [C-08] + + - id: AC-28 + description: "v1.10.0 source-inspection: ManageUserModal shows a Disable account control (POST '/api/v1/users/{id}:disable') when disabled_at is null and an Enable account control (POST '/api/v1/users/{id}:enable') when it is non-null; both invalidate ['users'] on success. The disabled state is surfaced as a Disabled status in both the modal and the UsersPage roster row (driven by disabled_at)." + priority: high + references_constraints: [C-08] + + - id: AC-29 + description: "v1.10.0 source-inspection: a 409 from disabling your own account is surfaced inline through the shared action-error Callout (apiErrorMessage carries the users.cannot_disable_self reason); admin reset-password and disable/enable copy contains no em-dash characters." + priority: medium + references_constraints: [C-08] diff --git a/specs/system/kensa-executor.spec.yaml b/specs/system/kensa-executor.spec.yaml index 38dc0ce7d..8df5090a1 100644 --- a/specs/system/kensa-executor.spec.yaml +++ b/specs/system/kensa-executor.spec.yaml @@ -10,7 +10,7 @@ spec: feature: Kensa scan execution bridge description: > The executor invokes Kensa (Go module github.com/Hanalyx/kensa - pinned to v0.5.0) to run a scan against a single host using the + pinned to v0.5.1) to run a scan against a single host using the FULL rule corpus applicable to the host's detected OS capabilities. The Kensa API (`Kensa.Scan(ctx, host, rules, opts...)` per kensa-go/api/kensa.go:228) takes a `[]*api.Rule` @@ -131,7 +131,7 @@ spec: type: technical enforcement: error - id: C-13 - description: The production scanFunc MUST compose the scan-only Kensa via api.New with pkg/kensa.NewScanner (kensa v0.5.0 — stateless, concurrency-safe shared) and this package's TransportFactory; no engine, store, or signer is constructed for the scan path. The worker subcommand binds it via WithScanFunc(NewProductionScanFunc(...)). unwiredScanFunc may remain ONLY as the test fallback NewExecutor defaults to before binding, annotated as such + description: The production scanFunc MUST compose the scan-only Kensa via api.New with pkg/kensa.NewScanner (kensa v0.5.1 — stateless, concurrency-safe shared) and this package's TransportFactory; no engine, store, or signer is constructed for the scan path. The worker subcommand binds it via WithScanFunc(NewProductionScanFunc(...)). unwiredScanFunc may remain ONLY as the test fallback NewExecutor defaults to before binding, annotated as such type: technical enforcement: error - id: C-14 diff --git a/specter.yaml b/specter.yaml index 3c02ec398..974a4aab6 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