diff --git a/.secrets.baseline b/.secrets.baseline index 7e759472c..4508dc0d2 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -149,7 +149,7 @@ "filename": "api/openapi.yaml", "hashed_secret": "6b1fe243c0b63c8e43d2545520492f2f5bc19869", "is_verified": false, - "line_number": 171 + "line_number": 195 } ], "cmd/openwatch/setup.go": [ @@ -558,14 +558,14 @@ "filename": "internal/server/api/server.gen.go", "hashed_secret": "9fd0aaae1a3d0bc789d081307161ea9a821f9dee", "is_verified": false, - "line_number": 4593 + "line_number": 4595 }, { "type": "Secret Keyword", "filename": "internal/server/api/server.gen.go", "hashed_secret": "eca525ee60b3564d9633eb140726685271d52341", "is_verified": false, - "line_number": 4731 + "line_number": 4733 } ], "internal/server/api_scans_test.go": [ @@ -818,5 +818,5 @@ } ] }, - "generated_at": "2026-09-21T17:55:15Z" + "generated_at": "2026-09-21T20:14:45Z" } diff --git a/api/openapi.yaml b/api/openapi.yaml index 17a76334e..3c58e7fef 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -73,6 +73,28 @@ x-anonymous-mutations: Same as postAuthRefresh, with the refresh token arriving in a cookie rather than the body. +x-anonymous-reads: + - operationId: getCapabilities + reason: > + A client needs to know which capabilities this deployment has before it has an identity, so it can present a locked control instead of discovering the gate from a 402. The response carries capability ids, tiers and availability, and no license, customer or deployment detail. + - operationId: getLicense + reason: > + Pre-auth by design (api-license C-06, C-09): an anonymous caller receives only tier, status and features, an allowlisted public field set; every other field is written only for a caller holding system:read, decided inside the handler. + - operationId: getAuthPermissionsRegistry + reason: > + The static permission registry: permission ids, descriptions, categories and the built-in role bundles, all of which USER_ROLES.md publishes in this public repository. It reads no table and returns no user, assignment, custom role or deployment setting (system-rbac AC-30). Retained anonymous by founder decision, 2026-09-21. + - operationId: getAuthMePermissions + reason: > + Introspection of the calling identity. An anonymous caller gets is_anonymous true and an empty permission list, which tells them only what they already are; the SPA uses it to decide whether to show the login page. + - operationId: getSSOProvidersEnabled + reason: > + The login page must list the identity providers before anyone is signed in (api-sso). The response carries provider id and name only. + - operationId: getAuthSSOLogin + reason: > + The SSO redirect that starts a sign-in; there is no identity yet (api-sso). + - operationId: getAuthSSOCallback + reason: > + The SSO redirect-back that exchanges the code and creates the session; there is no identity yet (api-sso). paths: /api/v1/auth/login: @@ -153,6 +175,7 @@ paths: /api/v1/auth/me: get: operationId: getAuthMe + x-requires-identity: true summary: Return the calling identity responses: '200': @@ -167,6 +190,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} patch: operationId: patchAuthMe + x-requires-identity: true summary: Update the calling user's own profile description: >- Self-service profile edit for the authenticated user. The body is a @@ -204,6 +228,7 @@ paths: /api/v1/users/me/preferences: get: operationId: getUsersMePreferences + x-requires-identity: true summary: Return the calling user's UI preferences description: > Per-user UI preferences (e.g. the /hosts grid-vs-table default). @@ -223,6 +248,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} patch: operationId: patchUsersMePreferences + x-requires-identity: true summary: Merge a partial update into the calling user's UI preferences description: > Shallow-merges the provided keys into the caller's stored @@ -253,6 +279,7 @@ paths: /api/v1/auth/mfa:enroll: post: operationId: postAuthMFAEnroll + x-requires-identity: true summary: Enroll the calling user in TOTP MFA responses: '200': @@ -264,6 +291,7 @@ paths: /api/v1/auth/mfa:verify: post: operationId: postAuthMFAVerify + x-requires-identity: true summary: Confirm an enrolled MFA secret by verifying a generated OTP requestBody: required: true @@ -282,6 +310,7 @@ paths: /api/v1/auth/password:change: post: operationId: postAuthPasswordChange + x-requires-identity: true summary: Change the calling user's password requestBody: required: true @@ -419,6 +448,7 @@ paths: /api/v1/admin/license:verify: post: operationId: postAdminLicenseVerify + x-required-permission: system:read summary: Dry-run validate a JWT license without installing it requestBody: required: true @@ -562,6 +592,7 @@ paths: /api/v1/diagnostics:enqueue-test-job: post: operationId: postDiagnosticsEnqueueTestJob + x-required-permission: system:config_write summary: Stage-0 queue demo; enqueues a no-op job processed by the in-process worker responses: '202': @@ -1242,6 +1273,7 @@ paths: /api/v1/audit/events: get: operationId: getAuditEvents + x-required-permission: audit:read summary: List audit events (cursor-paginated, newest first) parameters: - name: action @@ -1346,6 +1378,7 @@ paths: /api/v1/fleet/score: get: operationId: getFleetScore + x-required-permission: system:read summary: Fleet-wide compliance score (equal-host mean) parameters: - name: framework @@ -1372,6 +1405,7 @@ paths: /api/v1/fleet/liveness: get: operationId: getFleetLiveness + x-required-permission: system:read summary: Host counts by reachability status responses: '200': @@ -1393,6 +1427,7 @@ paths: /api/v1/fleet/top-failing-rules: get: operationId: getFleetTopFailingRules + x-required-permission: system:read summary: Rules with the most failing hosts (descending) parameters: - name: framework @@ -1423,6 +1458,7 @@ paths: /api/v1/fleet/top-failing-hosts: get: operationId: getFleetTopFailingHosts + x-required-permission: system:read summary: Hosts with the most failing rules (descending) parameters: - name: framework @@ -1453,6 +1489,7 @@ paths: /api/v1/fleet/recent-changes: get: operationId: getFleetRecentChanges + x-required-permission: system:read summary: Recent transactions (state changes), newest first parameters: - name: framework @@ -1487,6 +1524,7 @@ paths: /api/v1/fleet/connectivity/breakdown: get: operationId: getFleetConnectivityBreakdown + x-required-permission: system:read summary: 4-state connectivity breakdown (online/degraded/critical/down/never_probed) description: | Derived per-state host counts for the Settings → Scanning & @@ -1509,6 +1547,7 @@ paths: /api/v1/system/connectivity/config: get: operationId: getSystemConnectivityConfig + x-required-permission: system:read summary: Read the connectivity-monitor runtime config + baked-in defaults responses: '200': @@ -1523,6 +1562,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} put: operationId: putSystemConnectivityConfig + x-required-permission: system:config_write summary: Update connectivity-monitor runtime config description: | Persists the new config, signals the in-process liveness service @@ -1550,6 +1590,7 @@ paths: /api/v1/system/intelligence/config: get: operationId: getSystemIntelligenceConfig + x-required-permission: system:read summary: Read the OS Intelligence scheduler runtime config + baked-in defaults description: | Returns the persisted IntelligenceConfig (or DefaultIntelligence @@ -1570,6 +1611,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} put: operationId: putSystemIntelligenceConfig + x-required-permission: system:config_write summary: Update OS Intelligence scheduler runtime config description: | Persists the new config (interval_sec 300..86400, rate_limit @@ -1599,6 +1641,7 @@ paths: /api/v1/system/discovery/config: get: operationId: getSystemDiscoveryConfig + x-required-permission: system:read summary: Read the OS discovery scheduler runtime config + baked-in defaults description: | Returns the persisted DiscoveryConfig (or DefaultDiscovery when @@ -1619,6 +1662,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} put: operationId: putSystemDiscoveryConfig + x-required-permission: system:config_write summary: Update OS discovery scheduler runtime config description: | Persists the new config (interval_sec 3600..604800, rate_limit @@ -1648,6 +1692,7 @@ paths: /api/v1/system/discovery/sweep: post: operationId: postSystemDiscoverySweep + x-required-permission: system:config_write summary: Enqueue a host.discovery job for every host with NULL os_discovered_at description: | One-off manual sweep used by the "Run now" affordance on the @@ -1745,6 +1790,7 @@ paths: /api/v1/system/scan/config: get: operationId: getSystemScanConfig + x-required-permission: system:read summary: Read the adaptive compliance scan scheduler config + baked-in defaults description: | Returns the persisted ScanConfig (or DefaultScan when no row @@ -1766,6 +1812,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} put: operationId: putSystemScanConfig + x-required-permission: system:config_write summary: Update the adaptive compliance scan scheduler config description: | Persists the new config. Ladder minutes are CLAMPED into @@ -1795,6 +1842,7 @@ paths: /api/v1/system/scan/schedule-preview: get: operationId: getSystemScanSchedulePreview + x-required-permission: system:read summary: 24h forward projection of the compliance scan schedule description: | Read-only projection of host_compliance_schedule over the next @@ -1817,6 +1865,7 @@ paths: /api/v1/fleet/compliance/states: get: operationId: getFleetComplianceStates + x-required-permission: host:read summary: Fleet host counts per compliance state description: | One row per ComplianceState in ladder order (critical, @@ -1840,6 +1889,7 @@ paths: /api/v1/system/scan/variables: get: operationId: getSystemScanVariables + x-required-permission: system:read summary: List the corpus-used kensa rule-template variables with defaults and overrides description: | One entry per variable the installed rule corpus actually @@ -1863,6 +1913,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} put: operationId: putSystemScanVariables + x-required-permission: system:config_write summary: Replace the operator variable overrides description: | PUT replaces the FULL override map (absent names revert to the @@ -1938,6 +1989,7 @@ paths: /api/v1/hosts/{id}/exceptions: get: operationId: getHostExceptions + x-required-permission: exception:read summary: List a host's compliance exceptions description: | Per-host exception list for the Compliance tab + Watchlist row. @@ -1971,6 +2023,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} post: operationId: postHostException + x-required-permission: exception:request summary: Request a compliance exception (rule waiver) on a host description: | Submits a requested exception for a host+rule. One open @@ -2013,6 +2066,7 @@ paths: /api/v1/compliance/exceptions: get: operationId: getComplianceExceptions + x-required-permission: exception:read summary: Fleet-wide compliance exception queue description: | Fleet exception list, optionally filtered by status (requested, @@ -2042,6 +2096,7 @@ paths: /api/v1/exceptions/{xid}:approve: post: operationId: postExceptionApprove + x-required-permission: exception:approve summary: Approve a requested exception description: | requested -> approved. The reviewer must differ from the @@ -2081,6 +2136,7 @@ paths: /api/v1/exceptions/{xid}:reject: post: operationId: postExceptionReject + x-required-permission: exception:approve summary: Reject a requested exception description: | requested -> rejected. The reviewer must differ from the @@ -2120,6 +2176,7 @@ paths: /api/v1/exceptions/{xid}:revoke: post: operationId: postExceptionRevoke + x-required-permission: exception:revoke summary: Revoke an active exception before its expiry description: | approved -> revoked. RBAC: exception:revoke (dangerous). Spec @@ -2530,6 +2587,7 @@ paths: /api/v1/groups: get: operationId: getGroups + x-required-permission: host:read summary: Fleet group summary + every group with its rollup description: | Returns the Groups-page KPI summary and the full list of groups @@ -2553,6 +2611,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} post: operationId: postGroup + x-required-permission: host:write summary: Create a group description: | Create a site (manual membership) or an OS category (manual @@ -2593,6 +2652,7 @@ paths: /api/v1/groups/{id}: patch: operationId: patchGroup + x-required-permission: host:write summary: Update a group's display fields description: | Patch name, subtype, and color. Kind and membership are @@ -2635,6 +2695,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} delete: operationId: deleteGroup + x-required-permission: host:write summary: Delete a group description: | Removes the group (members cascade). RBAC: host:write. Spec @@ -2666,6 +2727,7 @@ paths: /api/v1/groups/{id}:maintenance: post: operationId: postGroupMaintenance + x-required-permission: host:write summary: Toggle a group's maintenance flag description: | Sets the maintenance flag on or off. RBAC: host:write. Spec @@ -2705,6 +2767,7 @@ paths: /api/v1/groups/{id}:target: post: operationId: postGroupTarget + x-required-permission: host:write summary: Set or clear a site group's compliance target framework description: | Sets (or clears, when target_framework is empty) the compliance @@ -2751,6 +2814,7 @@ paths: /api/v1/groups/{id}/members: post: operationId: postGroupMember + x-required-permission: host:write summary: Add a host to a manual group description: | Assigns a host to a manual group (auto groups derive their @@ -2793,6 +2857,7 @@ paths: /api/v1/groups/{id}/members/{host_id}: delete: operationId: deleteGroupMember + x-required-permission: host:write summary: Remove a host from a manual group description: | Removes a host from a manual group. RBAC: host:write. Spec @@ -2823,6 +2888,7 @@ paths: /api/v1/reports: get: operationId: getReports + x-required-permission: host:read summary: List generated reports (newest first) description: | Returns the Reports library: every generated report, newest @@ -2847,6 +2913,7 @@ paths: /api/v1/reports:generate: post: operationId: postReportGenerate + x-required-permission: host:write # Gated on the REQUEST, not the route: only report kind `attestation` # requires the entitlement. The rest of this route is free, so a # route-level x-required-feature would be a false claim. @@ -2889,6 +2956,7 @@ paths: /api/v1/reports/frameworks: get: operationId: getReportFrameworks + x-required-permission: host:read summary: List the framework lenses available across the fleet description: | Returns the distinct framework_refs keys present anywhere in the @@ -2915,6 +2983,7 @@ paths: /api/v1/reports/schedules: get: operationId: getReportSchedules + x-required-permission: host:read summary: List the report delivery schedules description: | Returns every scheduled report (daily/weekly/monthly cadence that @@ -2938,6 +3007,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} post: operationId: createReportSchedule + x-required-permission: host:write # Gated on the REQUEST, not the route: only report kind `attestation` # requires the entitlement. The rest of this route is free, so a # route-level x-required-feature would be a false claim. @@ -2977,6 +3047,7 @@ paths: /api/v1/reports/schedules/{id}: patch: operationId: updateReportSchedule + x-required-permission: host:write summary: Enable or disable a report schedule description: | Toggles a schedule's enabled flag. Re-enabling recomputes the next @@ -3014,6 +3085,7 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} delete: operationId: deleteReportSchedule + x-required-permission: host:write summary: Delete a report schedule description: | Removes a schedule (does not delete reports it already generated). @@ -3045,6 +3117,7 @@ paths: /api/v1/reports/signing-key: get: operationId: getReportSigningKey + x-required-permission: host:read summary: The public key for verifying report signatures description: | Returns the Ed25519 public key (with its key id) used to sign @@ -3075,6 +3148,7 @@ paths: /api/v1/reports/{id}: get: operationId: getReportByID + x-required-permission: host:read summary: Fetch one report (with its stored JSON posture) description: | Returns a single report by id, including the stored JSON @@ -3109,6 +3183,7 @@ paths: /api/v1/reports/{id}/export: get: operationId: getReportExport + x-required-permission: host:read # Gated on the REQUEST, not the route: only report kind `attestation` # requires the entitlement. The rest of this route is free, so a # route-level x-required-feature would be a false claim. @@ -3408,6 +3483,7 @@ paths: /api/v1/system/connectivity/status: get: operationId: getSystemConnectivityStatus + x-required-permission: system:read summary: Read the connectivity-monitor in-process metrics + maintenance flag responses: '200': @@ -3424,6 +3500,7 @@ paths: /api/v1/hosts/{id}/connectivity:check: post: operationId: postHostConnectivityCheck + x-required-permission: host:connectivity_check summary: Synchronous on-demand reachability probe for a single host description: | Calls the same in-process probe machinery the periodic loop uses @@ -3465,6 +3542,7 @@ paths: /api/v1/hosts/{id}/scans: post: operationId: postHostScan + x-required-permission: host:write summary: Enqueue an on-demand compliance scan for a single host description: | Creates a scan run (the scan_runs logbook row) and enqueues the @@ -3509,6 +3587,7 @@ paths: /api/v1/hosts/{id}/compliance: get: operationId: getHostCompliance + x-required-permission: host:read summary: Per-host compliance lens (one scan, many framework views) description: | Projects the host's full host_rule_state corpus through an @@ -3555,6 +3634,7 @@ paths: /api/v1/hosts/{id}/compliance/schedule: get: operationId: getHostComplianceSchedule + x-required-permission: host:read summary: One host's adaptive-scan schedule (Auto-scan tile) description: | The host's row from the adaptive scheduler: compliance state, @@ -3588,6 +3668,7 @@ paths: /api/v1/hosts/{id}/compliance/trend: get: operationId: getHostComplianceTrend + x-required-permission: host:read summary: Daily compliance posture trend for one host description: | Per-day posture snapshots over the trailing window (default 30 @@ -3628,6 +3709,7 @@ paths: /api/v1/fleet/compliance/trend: get: operationId: getFleetComplianceTrend + x-required-permission: host:read summary: Daily fleet compliance trend description: | Per-day fleet aggregates over the trailing window (default 30 @@ -3667,6 +3749,7 @@ paths: /api/v1/hosts/{id}/compliance/frameworks: get: operationId: getHostComplianceFrameworks + x-required-permission: host:read summary: List the frameworks the host's rule state maps to description: | Distinct framework_refs keys across the host's host_rule_state @@ -3699,6 +3782,7 @@ paths: /api/v1/hosts/{id}/compliance/failed-rules: get: operationId: getHostFailedRules + x-required-permission: host:read summary: List a host's currently-failing compliance rules description: | Returns host_rule_state rows with current_status=fail for the @@ -3743,6 +3827,7 @@ paths: /api/v1/fleet/scan-queue: get: operationId: getFleetScanQueue + x-required-permission: host:read summary: Scan-queue depth split by lifecycle state description: | Counts of scan_runs rows in the queued and running states. @@ -3763,6 +3848,7 @@ paths: /api/v1/intelligence/events: get: operationId: getIntelligenceEvents + x-required-permission: host:read summary: List OS Intelligence change events description: | Returns the change events the OS Intelligence collector @@ -3808,6 +3894,7 @@ paths: /api/v1/intelligence/state/{host_id}: get: operationId: getIntelligenceState + x-required-permission: host:read summary: Last full Intelligence snapshot for one host description: | Returns the most recent host_intelligence_state row for the @@ -3840,6 +3927,7 @@ paths: /api/v1/alerts: get: operationId: getAlerts + x-required-permission: alert:read summary: List persisted alerts (paginated, filterable) description: | Returns alerts in the alerts table — written by the @@ -3884,6 +3972,7 @@ paths: /api/v1/alerts/{id}: get: operationId: getAlertByID + x-required-permission: alert:read summary: Single alert by id description: | Returns the alert row (including terminal states — resolved / @@ -3913,6 +4002,7 @@ paths: /api/v1/alerts/{id}:acknowledge: post: operationId: postAlertAcknowledge + x-required-permission: alert:write summary: Acknowledge an active alert description: 'active -> acknowledged. RBAC: alert:write. Spec api-alerts.' parameters: @@ -3949,6 +4039,7 @@ paths: /api/v1/alerts/{id}:silence: post: operationId: postAlertSilence + x-required-permission: alert:write summary: Silence an alert until a timestamp (or indefinitely) description: | active|acknowledged -> silenced. Optional body.until specifies @@ -3989,6 +4080,7 @@ paths: /api/v1/alerts/{id}:resolve: post: operationId: postAlertResolve + x-required-permission: alert:write summary: Resolve an alert description: 'active|acknowledged|silenced -> resolved. RBAC: alert:write. Spec api-alerts.' parameters: @@ -4025,6 +4117,7 @@ paths: /api/v1/alerts/{id}:dismiss: post: operationId: postAlertDismiss + x-required-permission: alert:write summary: Dismiss an alert (terminal) description: 'Any non-dismissed state -> dismissed. RBAC: alert:write. Spec api-alerts.' parameters: @@ -4061,6 +4154,7 @@ paths: /api/v1/activity: get: operationId: getActivity + x-requires-identity: true summary: Unified Activity feed (UNION over alerts + transactions + intelligence + audit + monitoring) description: | Returns the per-source RBAC-filtered union of recent events. @@ -4105,6 +4199,7 @@ paths: /api/v1/hosts/{id}/system-info: get: operationId: getHostSystemInfo + x-required-permission: host:read summary: Read the latest Discovery facts for one host description: | Returns the most recent host_system_info row for the given @@ -4139,6 +4234,7 @@ paths: /api/v1/hosts/{id}/discovery:run: post: operationId: postHostDiscoveryRun + x-required-permission: host:write summary: Synchronous on-demand OS fingerprint discovery for a single host description: | Opens one SSH session against the host using its resolved @@ -4189,6 +4285,7 @@ paths: /api/v1/notifications/feed: get: operationId: getNotificationFeed + x-requires-identity: true summary: List the calling user's in-app notifications (the bell) description: > Self-scoped: any authenticated identity reads its own notifications; no @@ -4215,6 +4312,7 @@ paths: /api/v1/notifications/feed:read-all: post: operationId: postNotificationFeedReadAll + x-requires-identity: true summary: Mark all the calling user's notifications read responses: '200': @@ -4226,6 +4324,7 @@ paths: /api/v1/notifications/feed/{id}:read: post: operationId: postNotificationFeedRead + x-requires-identity: true summary: Mark one notification read (must belong to the caller) parameters: - name: id @@ -5521,7 +5620,7 @@ components: required: [scope, name, username, auth_method] properties: scope: {type: string, enum: [system, host]} - scope_id: {type: string, format: uuid, nullable: true} + scope_id: {type: string, format: uuid, nullable: true, description: 'Required when scope=host (the host id); must be absent when scope=system.'} name: {type: string, minLength: 1, maxLength: 256} description: {type: string, maxLength: 1024} username: {type: string, minLength: 1, maxLength: 256} diff --git a/frontend/src/api/host-view-model.ts b/frontend/src/api/host-view-model.ts index d0b11ddcc..9b638f179 100644 --- a/frontend/src/api/host-view-model.ts +++ b/frontend/src/api/host-view-model.ts @@ -5,12 +5,7 @@ // monitoring_state distinguishes WHICH layer is failing (sudo broken vs ssh // down vs network outage). 'status' stays as the coarse online/down view. export type MonitoringBand = - | 'online' - | 'degraded' - | 'critical' - | 'down' - | 'maintenance' - | 'unknown'; + 'online' | 'degraded' | 'critical' | 'down' | 'maintenance' | 'unknown'; export interface DevHost { id: string; diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index ea7ce9cd3..e05109034 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -3512,7 +3512,10 @@ export interface components { CredentialCreateRequest: { /** @enum {string} */ scope: "system" | "host"; - /** Format: uuid */ + /** + * Format: uuid + * @description Required when scope=host (the host id); must be absent when scope=system. + */ scope_id?: string | null; name: string; description?: string; diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 400e8d3b5..503959d88 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -1543,8 +1543,7 @@ function RemediationRowAction({ 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'; + '/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: {}, diff --git a/frontend/src/pages/settings/ScanningPage.tsx b/frontend/src/pages/settings/ScanningPage.tsx index d132aa8d2..626f84cb7 100644 --- a/frontend/src/pages/settings/ScanningPage.tsx +++ b/frontend/src/pages/settings/ScanningPage.tsx @@ -69,12 +69,7 @@ interface StateRowConfig { } type ScanStateId = - | 'critical' - | 'non_compliant' - | 'partial' - | 'mostly_compliant' - | 'compliant' - | 'unknown'; + 'critical' | 'non_compliant' | 'partial' | 'mostly_compliant' | 'compliant' | 'unknown'; interface ComplianceRowSeed { id: ScanStateId; diff --git a/internal/server/api/server.gen.go b/internal/server/api/server.gen.go index 3b620a8fa..94ae82c28 100644 --- a/internal/server/api/server.gen.go +++ b/internal/server/api/server.gen.go @@ -2285,8 +2285,10 @@ type CredentialCreateRequest struct { PrivateKey *string `json:"private_key,omitempty"` PrivateKeyPassphrase *string `json:"private_key_passphrase,omitempty"` Scope CredentialCreateRequestScope `json:"scope"` - ScopeId *openapi_types.UUID `json:"scope_id,omitempty"` - Username string `json:"username"` + + // ScopeId Required when scope=host (the host id); must be absent when scope=system. + ScopeId *openapi_types.UUID `json:"scope_id,omitempty"` + Username string `json:"username"` } // CredentialCreateRequestAuthMethod defines model for CredentialCreateRequest.AuthMethod. diff --git a/internal/server/rbac_classes_test.go b/internal/server/rbac_classes_test.go new file mode 100644 index 000000000..8a8352707 --- /dev/null +++ b/internal/server/rbac_classes_test.go @@ -0,0 +1,314 @@ +// @spec system-rbac +package server + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/Hanalyx/openwatch/internal/auth" + "gopkg.in/yaml.v3" +) + +// Spec: specs/system/rbac.spec.yaml +// +// AC-29 TestRBACClasses_EveryOperationDeclaresOneClassAndTheHandlerAgrees +// AC-30 TestRBACClasses_AnonymousRegistryExposesNothingDynamic + +const anonymousReadsKey = "x-anonymous-reads" + +// contractOperation is one operation as the classification test reads it. +type contractOperation struct { + Route string + Method string + Permission string + Identity bool + Security *[]any // nil when the operation inherits; a pointer to an empty list when it declares security: [] +} + +// allOperations reads every operation in api/openapi.yaml, every method, +// with the three fields the classification depends on. +func allOperations(t *testing.T) map[string]contractOperation { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "api", "openapi.yaml")) + if err != nil { + t.Fatalf("read openapi.yaml: %v", err) + } + var doc struct { + Paths map[string]map[string]struct { + OperationID string `yaml:"operationId"` + Permission string `yaml:"x-required-permission"` + Identity bool `yaml:"x-requires-identity"` + Security *[]any `yaml:"security"` + } `yaml:"paths"` + } + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatalf("parse openapi.yaml: %v", err) + } + out := map[string]contractOperation{} + for path, item := range doc.Paths { + for method, op := range item { + switch method { + case "get", "post", "put", "patch", "delete": + default: + continue + } + if op.OperationID == "" { + t.Errorf("%s %s has no operationId", strings.ToUpper(method), path) + continue + } + out[op.OperationID] = contractOperation{ + Route: strings.ToUpper(method) + " " + path, Method: method, + Permission: strings.TrimSpace(op.Permission), Identity: op.Identity, Security: op.Security, + } + } + } + return out +} + +// namedAllowlist reads one of the two top-level allowlists. +func namedAllowlist(t *testing.T, key string) []allowlistEntry { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "api", "openapi.yaml")) + if err != nil { + t.Fatalf("read openapi.yaml: %v", err) + } + var doc map[string]yaml.Node + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatalf("parse openapi.yaml: %v", err) + } + node, ok := doc[key] + if !ok { + return nil + } + var out []allowlistEntry + if err := node.Decode(&out); err != nil { + t.Fatalf("parse %s: %v", key, err) + } + return out +} + +// enforcePermissionCall matches a route gate on a typed constant. It is +// narrower than authQualifiedRef on purpose: GetLicense reads +// HasPermission(auth.SystemRead) to decide which fields to write and lets +// an anonymous caller through, which is a field gate, not a route gate. +var enforcePermissionCall = regexp.MustCompile(`EnforcePermission\(\s*w\s*,\s*r\s*,\s*(auth\.[A-Z][A-Za-z0-9]*|[a-z][A-Za-z0-9]*)\s*\)`) + +// @ac AC-29 +// AC-29: every operation, GET included, declares exactly one authorization +// class in the contract, and the handler source agrees with the class. +func TestRBACClasses_EveryOperationDeclaresOneClassAndTheHandlerAgrees(t *testing.T) { + t.Run("system-rbac/AC-29", func(t *testing.T) { + ops := allOperations(t) + if len(ops) == 0 { + t.Fatal("no operations parsed; the contract or this parser is broken") + } + src := serverSource(t) + constOf := permConstByValue(t) + permConstNames := map[string]bool{} + for _, name := range constOf { + permConstNames[name] = true + } + + // Both allowlists: every entry justified, live, and unique across + // the two lists together. + anon := map[string]string{} + for _, key := range []string{anonymousAllowlistKey, anonymousReadsKey} { + for _, e := range namedAllowlist(t, key) { + id := strings.TrimSpace(e.OperationID) + if id == "" { + t.Errorf("%s has an entry with no operationId", key) + continue + } + if strings.TrimSpace(e.Reason) == "" { + t.Errorf("%s entry %q carries no reason", key, id) + } + op, live := ops[id] + if !live { + t.Errorf("%s names %q, which is not an operation in the contract", key, id) + continue + } + if key == anonymousAllowlistKey && op.Method == "get" { + t.Errorf("%s names %q, a GET; reads belong on %s", key, id, anonymousReadsKey) + } + if key == anonymousReadsKey && op.Method != "get" { + t.Errorf("%s names %q, a %s; mutations belong on %s", key, id, strings.ToUpper(op.Method), anonymousAllowlistKey) + } + if prev, dup := anon[id]; dup { + t.Errorf("%q appears on both %s and %s", id, prev, key) + } + anon[id] = key + } + } + + // A route gate on a typed permission constant, following delegation + // the way AC-18 does. Reports the constant it found so an + // allowlisted route that later gained a gate is named precisely. + routeGate := func(body string) bool { + for _, m := range enforcePermissionCall.FindAllStringSubmatch(body, -1) { + if strings.HasPrefix(m[1], "auth.") && permConstNames[strings.TrimPrefix(m[1], "auth.")] { + return true + } + if !strings.HasPrefix(m[1], "auth.") { + // A permission passed in as a parameter (reviewException, + // lifecycle). The caller's body names the constant, and + // AC-18 pins which one; here it is enough that a gate runs. + return true + } + } + return false + } + // An identity gate reads the bound identity and refuses when there + // is none. 401 is the canonical refusal; api-activity C-01 mandates + // 403 for its route, which is why both are accepted here. + identityGate := func(body string) bool { + reads := strings.Contains(body, "IsAnonymous") || strings.Contains(body, "callerUUID(") + refuses := strings.Contains(body, "http.StatusUnauthorized") || strings.Contains(body, "http.StatusForbidden") + return reads && refuses + } + + var problems []string + perm, ident, anonCount := 0, 0, 0 + for opID, op := range ops { + classes := []string{} + if op.Permission != "" { + classes = append(classes, "x-required-permission") + } + if op.Identity { + classes = append(classes, "x-requires-identity") + } + _, listed := anon[opID] + securityEmpty := op.Security != nil && len(*op.Security) == 0 + if listed || securityEmpty { + classes = append(classes, "anonymous") + } + if len(classes) != 1 { + problems = append(problems, op.Route+" ("+opID+") declares "+strings.Join(classes, "+")+"; exactly one class is required") + continue + } + body := handlerBody(src, opID) + if body == "" { + problems = append(problems, op.Route+" ("+opID+") has no handler; its class cannot be checked") + continue + } + switch classes[0] { + case "x-required-permission": + perm++ // AC-18 checks the constant matches the declaration + case "x-requires-identity": + ident++ + if !enforcesFunc(src, body, identityGate, 0) { + problems = append(problems, op.Route+" ("+opID+") declares x-requires-identity but its handler does not read the identity and refuse an anonymous caller") + } + if enforcesFunc(src, body, routeGate, 0) { + problems = append(problems, op.Route+" ("+opID+") declares x-requires-identity but its handler enforces a permission; declare x-required-permission instead") + } + case "anonymous": + anonCount++ + if enforcesFunc(src, body, routeGate, 0) { + problems = append(problems, op.Route+" ("+opID+") is allowlisted as anonymous but its handler enforces a permission; delete the allowlist entry and declare the permission") + } + } + } + sort.Strings(problems) + for _, p := range problems { + t.Error(p) + } + if perm+ident+anonCount != len(ops) { + t.Errorf("accounting does not close: %d operations, but %d permission + %d identity + %d anonymous = %d", + len(ops), perm, ident, anonCount, perm+ident+anonCount) + } + if len(problems) == 0 { + t.Logf("%d operations: %d declare a permission, %d require an identity, %d anonymous (%d on %s, %d on %s, rest security: [])", + len(ops), perm, ident, anonCount, countKey(anon, anonymousAllowlistKey), anonymousAllowlistKey, + countKey(anon, anonymousReadsKey), anonymousReadsKey) + } + }) +} + +func countKey(m map[string]string, key string) int { + n := 0 + for _, v := range m { + if v == key { + n++ + } + } + return n +} + +// @ac AC-30 +// AC-30: the anonymous registry exposes nothing dynamic: no user, no +// assignment, no custom role, no configuration value. Checked at runtime +// as an anonymous caller and in the handler's source. +func TestRBACClasses_AnonymousRegistryExposesNothingDynamic(t *testing.T) { + t.Run("system-rbac/AC-30", func(t *testing.T) { + url, _ := freshAPIServer(t) + req, err := http.NewRequest("GET", url+"/api/v1/auth/permissions:registry", nil) + if err != nil { + t.Fatal(err) + } + resp := doReq(t, req) // no cookie, no bearer: anonymous + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("anonymous registry status = %d, want 200", resp.StatusCode) + } + raw, _ := io.ReadAll(resp.Body) + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + t.Fatalf("decode: %v", err) + } + keys := make([]string, 0, len(top)) + for k := range top { + keys = append(keys, k) + } + sort.Strings(keys) + if strings.Join(keys, ",") != "categories,permissions,roles" { + t.Errorf("registry keys = %v, want exactly categories, permissions, roles", keys) + } + var roles []struct { + ID string `json:"id"` + IsBuiltIn bool `json:"is_built_in"` + } + if err := json.Unmarshal(top["roles"], &roles); err != nil { + t.Fatalf("decode roles: %v", err) + } + got := map[string]bool{} + for _, r := range roles { + if !r.IsBuiltIn { + t.Errorf("registry role %q is not built in; custom roles must not reach the anonymous registry", r.ID) + } + got[r.ID] = true + } + for id := range auth.BuiltInRoles { + if !got[string(id)] { + t.Errorf("built-in role %q missing from the registry", id) + } + } + if len(got) != len(auth.BuiltInRoles) { + t.Errorf("registry has %d roles, want the %d built-in roles only", len(got), len(auth.BuiltInRoles)) + } + body := strings.ToLower(string(raw)) + for _, forbidden := range []string{"email", "username", "password", "user_id", "assigned", "dsn", "secret"} { + if strings.Contains(body, `"`+forbidden+`"`) { + t.Errorf("registry body carries a %q key; the anonymous registry must be static", forbidden) + } + } + + // Source half: the handler reads only the generated registry. + src := serverSource(t) + hb := handlerBody(src, "getAuthPermissionsRegistry") + if hb == "" { + t.Fatal("GetAuthPermissionsRegistry handler not found") + } + for _, reach := range []string{"h.pool", "h.users", "h.roles", "pgx", "Query(", "QueryRow("} { + if strings.Contains(hb, reach) { + t.Errorf("GetAuthPermissionsRegistry touches %s; the anonymous registry must come from the generated registry only", reach) + } + } + }) +} diff --git a/specs/system/rbac.spec.yaml b/specs/system/rbac.spec.yaml index 7a0776440..1abae577b 100644 --- a/specs/system/rbac.spec.yaml +++ b/specs/system/rbac.spec.yaml @@ -9,7 +9,7 @@ spec: # bugs/OW-054, reproduced 2026-09-19). AC-05's text is unchanged; it was # always the requirement and is now met for custom roles. Primary-role # binding (system-user-management C-06) is unchanged. - version: "2.3.0" + version: "2.4.0" status: approved tier: 1 @@ -92,7 +92,7 @@ spec: type: security enforcement: error - id: C-10 - description: "A mutating operation is never anonymously open by default. Every POST, PUT, PATCH and DELETE in api/openapi.yaml MUST resolve to exactly one of two states. Either its handler provably enforces the caller (an RBAC check on a typed permission, or an authentication check for a route that needs no specific permission), or its operationId appears on the named anonymous allowlist that api/openapi.yaml carries. An operation that is on neither is a defect, and the rule is written so the next author has a question to answer rather than a list to keep. The allowlist is the small side on purpose: it holds the pre-auth routes such as login and setup, and nothing joins it without a reason recorded next to it. This constraint is about REACHABILITY, not documentation. It does not claim the contract describes the gate: today many operations enforce inside the handler while declaring no x-required-permission, so a rule written against the annotation alone would report a contract gap as if it were an open route. Closing that annotation gap is separate work, and C-09 plus AC-18 govern the declarations that do exist." + description: "A mutating operation is never anonymously open by default. Every POST, PUT, PATCH and DELETE in api/openapi.yaml MUST resolve to exactly one of two states. Either its handler provably enforces the caller (an RBAC check on a typed permission, or an authentication check for a route that needs no specific permission), or its operationId appears on the named anonymous allowlist that api/openapi.yaml carries. An operation that is on neither is a defect, and the rule is written so the next author has a question to answer rather than a list to keep. The allowlist is the small side on purpose: it holds the pre-auth routes such as login and setup, and nothing joins it without a reason recorded next to it. This constraint is about REACHABILITY, not documentation. It does not claim the contract describes the gate: today many operations enforce inside the handler while declaring no x-required-permission, so a rule written against the annotation alone would report a contract gap as if it were an open route. That annotation gap was closed in 2.4.0 (CP bugs/OW-011): every operation in api/openapi.yaml, reads included, now declares exactly one authorization class, and C-12 governs the declarations." type: security enforcement: error - id: C-11 @@ -114,6 +114,25 @@ spec: type: technical enforcement: error + - id: C-12 + description: > + The contract states the gate for every operation, reads included. + Each operation in api/openapi.yaml MUST carry exactly one of three + classes: x-required-permission naming a registry permission the + handler enforces (AC-18); x-requires-identity true for a route that + needs a bound identity and no specific permission, whose handler + reads the identity and refuses an anonymous caller; or anonymous, + which means security is the empty list or the operationId appears + on a top-level allowlist with a reason recorded next to it + (x-anonymous-mutations for POST, PUT, PATCH and DELETE; + x-anonymous-reads for GET). A handler that enforces a permission + constant while its operation is allowlisted, or that enforces + nothing while its operation claims a permission, is a defect. The + classification is read by a structural test that walks the handler + source, so the annotation is documentation with teeth rather than a + list someone keeps. + type: security + enforcement: error acceptance_criteria: - id: AC-01 description: Codegen produces permissions.gen.go with one typed Permission constant per entry in permissions.yaml. @@ -388,3 +407,33 @@ spec: not this contract's. priority: critical references_constraints: [C-11] + - id: AC-29 + description: > + Every operation resolves to one declared class and the handler + agrees. A test walks every operation in api/openapi.yaml, GET + included, and requires exactly one of x-required-permission, + x-requires-identity, or anonymous (security empty, or an entry on + x-anonymous-mutations or x-anonymous-reads). For an identity-only + operation the handler must read the bound identity and refuse an + anonymous caller (401, or 403 where a contract such as api-activity + C-01 says so). For an anonymous operation the handler must not + enforce a permission constant, every allowlist entry must carry a + reason and name a live operation, and no operationId may appear on + both lists. The count closes: permission plus identity plus anonymous + equals the operation total, and a parser that dropped an operation + would fail the arithmetic rather than pass by silence. + priority: critical + references_constraints: [C-12] + - id: AC-30 + description: > + The anonymous permission registry exposes nothing dynamic. GET + /api/v1/auth/permissions:registry as an anonymous caller returns 200 + with exactly the keys categories, permissions and roles; every role + entry is a built-in role (is_built_in true) and the set of role ids + equals the built-in set; no entry carries a user id, email, username, + assignment or configuration value; and the handler's source reads + only the generated registry (no database pool, users service or + roles store), so a custom role or a user can never reach the + response by a later edit without this criterion noticing. + priority: high + references_constraints: [C-12]