From 3de994bbfb19dea730a545653b5a3c361fa06c79 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 21 Sep 2026 13:13:59 -0400 Subject: [PATCH 1/3] style(frontend): format three files prettier flags on main Format-only. The pre-commit prettier hook checks the whole frontend directory, so any commit that touches a frontend file is blocked by drift that landed earlier on main (24057cb3, 1507b799). No CI job runs this check. No logic changes. --- frontend/src/api/host-view-model.ts | 7 +------ frontend/src/pages/HostDetailPage.tsx | 3 +-- frontend/src/pages/settings/ScanningPage.tsx | 7 +------ 3 files changed, 3 insertions(+), 14 deletions(-) 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/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; From fe4d522e4da77eb492cc5646a5a182aa755e01f7 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 21 Sep 2026 13:14:44 -0400 Subject: [PATCH 2/3] feat(contract): declare the authorization class of every operation api/openapi.yaml now states the gate for all 159 operations, reads included, so a reader learns which routes are guarded from the contract rather than from handler source (CP bugs/OW-011, filed 2026-08-12, accepted for the GA cycle 2026-09-21). Census before this change, taken against main a5a05056: 69 operations declared x-required-permission; 68 enforced a permission in the handler and declared nothing; 11 required an identity and no permission; 11 were anonymous with no declaration beyond the four entries on x-anonymous-mutations. The 2026-08-12 census counted 43 undeclared mutations; today's count of 85 undeclared operations is the same defect with reads added, not a regression. No operation was found open. Three classes, exactly one per operation (system-rbac 2.4.0, C-12): x-required-permission, derived from the EnforcePermission constant each handler passes (66 added; the exception review routes pass the constant through reviewException); x-requires-identity true for the 11 routes that read the identity and refuse an anonymous caller; and anonymous, which is security [] or an entry with a reason on x-anonymous-mutations or the new x-anonymous-reads. The seven anonymous reads each carry the reason they are open and what they return; the permission registry stays anonymous by founder decision and its entry says what it never returns. Tests. AC-18 now verifies 135 declared permissions against the handlers (was 69). AC-29 walks every operation, requires exactly one class, checks an identity route reads and refuses, checks an allowlisted route enforces no permission, and closes the arithmetic at 159. AC-30 calls the registry anonymously and asserts exactly categories, permissions and roles, only built-in roles, no user or assignment or secret key, and a handler source that reaches no pool or store. Mutation checks: a wrong declaration, a missing declaration, and a gated read allowlisted as anonymous each fail by name. Also carried: the scope_id description on CredentialCreateRequest that the OW-065 documentation PR deferred here because it regenerates code. Generated Go and TypeScript follow; the x- extensions add no code. The detect-secrets baseline is the hook's own line-number refresh. One contract inconsistency is recorded, not changed: api-activity C-01 mandates 403 for an anonymous caller where every other route answers 401 auth.required; AC-29 accepts either refusal for that reason. CP: bugs/doing/OW-011 --- .secrets.baseline | 8 +- api/openapi.yaml | 101 ++++++++- frontend/src/api/schema.d.ts | 5 +- internal/server/api/server.gen.go | 6 +- internal/server/rbac_classes_test.go | 314 +++++++++++++++++++++++++++ specs/system/rbac.spec.yaml | 53 ++++- 6 files changed, 477 insertions(+), 10 deletions(-) create mode 100644 internal/server/rbac_classes_test.go diff --git a/.secrets.baseline b/.secrets.baseline index ebd9373f1..6b682f87f 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-19T22:42:43Z" + "generated_at": "2026-09-21T17:14:31Z" } 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/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/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] From b3d536119e4afd2dc85fe5e407b8ed1ace72b8de Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Mon, 21 Sep 2026 16:14:59 -0400 Subject: [PATCH 3/3] chore(ci): rescan the secrets baseline for the refreshed tree Merging main kept main's baseline, which preserved all 84 fingerprints but left three line numbers pointing above where this branch's inserted contract lines moved the findings (api/openapi.yaml 171 to 195, server.gen.go 4593 to 4595 and 4731 to 4733). Rescanned with the pinned detect-secrets 1.5.0 against the baseline: no finding added or removed; three locations corrected. --- .secrets.baseline | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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" }