mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-11-03 00:53:58 +00:00
28172a907b
Pulse Assistant was a chat drawer beside the monitoring UI. Operators who want to talk to their infrastructure already use Claude Code, Codex, or another MCP client, and Pulse MCP gives those agents the same governed tools. Keeping a second, weaker chat in the web app cost a 66 kB chunk, a launcher on every page, hand-off buttons on alerts, findings, approvals and drawers, and thousands of lines of OpenCode-parity drawer code, for a feature the maintainer judged ineffective. This removes the browser chat and everything that only fed it: - The drawer, its launcher (desktop and phone), the command-palette Assistant commands, the Escape and blocking-dialog shell plumbing, and the chat store, client, dev stream fixture and generated SSE types. - Every "Open in Assistant", "Explain with Assistant", "Discuss" and "Ask Assistant" hand-off on alerts, incident timelines, Patrol findings, approvals, run history, the attention workbench and resource drawers, with their context models and the chat-only helpers they used. - The Assistant settings page and its session maintenance section. The page becomes "External agents" at /settings/pulse-intelligence/agents; the old /assistant path, ?tab=assistant and system-ai-assistant redirect there with the hash kept. Alert Investigate now starts a targeted Patrol run (resource, alert ID and type) and says where the findings land, and it ignores repeat clicks while the request is in flight. The run route needs an admin with ai:execute, so security status gains settingsCapabilities.patrolRun, derived from the same snapshot as the other admin capabilities but following the route for org-scoped admins, organization-bound tokens and a cookie beside a token, and Investigate is offered only where it is true. The Patrol tool transcript block moves to components/patrol. A small aiAvailability store holds the session assistantEnabled fact that gates Patrol entry points and Investigate; it now clears and refreshes on organization switch, refreshes when organization loading settles on another org and after provider removal, and ignores answers that arrive after a newer one. The backend chat service stays: Patrol investigations run on it and Pulse Mobile chat uses it until 31 March 2027, so /api/ai/chat, the session endpoints and the stream contract are unchanged. A new contract snapshot pins the steer_applied event that only the deleted TypeScript projection used to cover. highlight.js, used only by the chat's code highlighter, leaves the dependencies, as does the DOMPurify carve-out that kept its fence hints. Contracts, registry and readiness proofs drop the retired surfaces; the ai-runtime contract loses about 1,600 lines of drawer rules while keeping every backend session, stream and provider rule. The production build ships about 100 kB less gzipped JavaScript.
423 lines
17 KiB
Go
423 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/edition"
|
|
)
|
|
|
|
type securityStatusSettingsCapabilities struct {
|
|
// InfrastructureRead mirrors the RequireAdmin + settings:read gate that
|
|
// every data source behind Settings → Infrastructure enforces
|
|
// (/api/connections, /api/config/nodes, /api/system/settings,
|
|
// /api/truenas/connections, /api/vmware/connections, and /api/discover,
|
|
// which needs settings:write on top). Serving it lets the nav hide a page
|
|
// that would otherwise mount 15s and 30s pollers whose every request is
|
|
// refused and logged at warn level.
|
|
InfrastructureRead bool `json:"infrastructureRead"`
|
|
// AvailabilityRead, DiagnosticsRead, and SystemLogsRead mirror the
|
|
// RequireAdmin + settings:read guards on the read routes mounted by those
|
|
// Settings panels.
|
|
AvailabilityRead bool `json:"availabilityRead"`
|
|
DiagnosticsRead bool `json:"diagnosticsRead"`
|
|
SystemLogsRead bool `json:"systemLogsRead"`
|
|
// PulseIntelligenceRead also includes read:settings authorization because
|
|
// /api/settings/ai applies RequirePermission before its handler repeats the
|
|
// settings:read admin/scope check.
|
|
PulseIntelligenceRead bool `json:"pulseIntelligenceRead"`
|
|
// ReportingRead includes read:nodes authorization as well as the explicit
|
|
// admin session and settings:read checks on the reporting catalog route.
|
|
// The advanced_reporting feature gate remains a separate UI concern.
|
|
ReportingRead bool `json:"reportingRead"`
|
|
// SystemSettingsRead mirrors the same RequireAdmin + settings:read gate for
|
|
// the System → Network, Pulse server updates, and Recovery tabs, whose
|
|
// panels read and write the public URL and CORS boundaries, the server
|
|
// update channel, and backup polling plus config export/import. It is a
|
|
// sibling of InfrastructureRead rather than a reuse of it: they happen to
|
|
// share a derivation today, but each names the surface it describes, so
|
|
// tightening one gate cannot silently hide the other's tabs. System →
|
|
// General stays ungated - theme, language, and unit preferences there are
|
|
// user-scoped, not instance administration.
|
|
SystemSettingsRead bool `json:"systemSettingsRead"`
|
|
// PatrolRun answers the RequireAdmin + ai:execute gate on
|
|
// POST /api/ai/patrol/run from this snapshot (securityStatusCanRunPatrol),
|
|
// so a surface that starts a Patrol run (alert Investigate) is offered to
|
|
// the sessions the route accepts.
|
|
PatrolRun bool `json:"patrolRun"`
|
|
APIAccessRead bool `json:"apiAccessRead"`
|
|
APIAccessWrite bool `json:"apiAccessWrite"`
|
|
AuthenticationRead bool `json:"authenticationRead"`
|
|
AuthenticationWrite bool `json:"authenticationWrite"`
|
|
SingleSignOnRead bool `json:"singleSignOnRead"`
|
|
SingleSignOnWrite bool `json:"singleSignOnWrite"`
|
|
Roles bool `json:"roles"`
|
|
Users bool `json:"users"`
|
|
AuditLog bool `json:"auditLog"`
|
|
AuditWebhooksRead bool `json:"auditWebhooksRead"`
|
|
AuditWebhooksWrite bool `json:"auditWebhooksWrite"`
|
|
RelayRead bool `json:"relayRead"`
|
|
RelayWrite bool `json:"relayWrite"`
|
|
BillingAdmin bool `json:"billingAdmin"`
|
|
}
|
|
|
|
type securityStatusSessionCapabilities struct {
|
|
DemoMode bool `json:"demoMode"`
|
|
AssistantEnabled bool `json:"assistantEnabled"`
|
|
}
|
|
|
|
type securityStatusPresentationPolicy struct {
|
|
DemoMode bool `json:"demoMode"`
|
|
ReadOnly bool `json:"readOnly"`
|
|
HideCommercial bool `json:"hideCommercial"`
|
|
HideUpgrade bool `json:"hideUpgrade"`
|
|
}
|
|
|
|
type securityStatusAuthSnapshot struct {
|
|
request *http.Request
|
|
authenticated bool
|
|
authMethod string
|
|
username string
|
|
proxyIsAdmin bool
|
|
sessionIsAdmin bool
|
|
// sessionManagesOrg is set for an org-scoped session that
|
|
// ensureOrgAdminSession admits: an owner or admin of the request's
|
|
// organization, or the configured admin. It widens only the org-bound
|
|
// settings surfaces, never sessionIsAdmin, so instance administration and
|
|
// the privileged status fields stay closed to org-scoped sessions.
|
|
sessionManagesOrg bool
|
|
tokenRecord *config.APITokenRecord
|
|
}
|
|
|
|
func (s securityStatusAuthSnapshot) tokenScopes() []string {
|
|
if s.tokenRecord == nil {
|
|
return nil
|
|
}
|
|
return append([]string{}, s.tokenRecord.Scopes...)
|
|
}
|
|
|
|
func (s securityStatusAuthSnapshot) hasScopes(scopes ...string) bool {
|
|
if s.tokenRecord == nil {
|
|
return true
|
|
}
|
|
for _, scope := range scopes {
|
|
if scope == "" {
|
|
continue
|
|
}
|
|
if !s.tokenRecord.HasScope(scope) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s securityStatusAuthSnapshot) passesPrivilegedSessionGate() bool {
|
|
if !s.authenticated {
|
|
return false
|
|
}
|
|
if s.authMethod == "session" {
|
|
return s.sessionIsAdmin
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s securityStatusAuthSnapshot) canAccessAdminSurface(scopes ...string) bool {
|
|
if !s.authenticated {
|
|
return false
|
|
}
|
|
|
|
switch s.authMethod {
|
|
case "proxy":
|
|
if !s.proxyIsAdmin {
|
|
return false
|
|
}
|
|
case "session":
|
|
if !s.sessionIsAdmin {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return s.hasScopes(scopes...)
|
|
}
|
|
|
|
func (r *Router) buildSecurityStatusAuthSnapshot(req *http.Request) securityStatusAuthSnapshot {
|
|
if r == nil || req == nil || r.config == nil {
|
|
return securityStatusAuthSnapshot{}
|
|
}
|
|
|
|
if adminBypassEnabled() {
|
|
snapshotReq := attachAdminBypassContext(attachUserContext(req, "admin"))
|
|
return securityStatusAuthSnapshot{
|
|
request: snapshotReq,
|
|
authenticated: true,
|
|
authMethod: "bypass",
|
|
username: "admin",
|
|
sessionIsAdmin: true,
|
|
}
|
|
}
|
|
|
|
if r.config.ProxyAuthSecret != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid {
|
|
snapshotReq := req
|
|
if username != "" {
|
|
snapshotReq = attachUserContext(req, username)
|
|
}
|
|
return securityStatusAuthSnapshot{
|
|
request: snapshotReq,
|
|
authenticated: true,
|
|
authMethod: "proxy",
|
|
username: username,
|
|
proxyIsAdmin: isAdmin,
|
|
sessionIsAdmin: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
if token := strings.TrimSpace(req.Header.Get("X-API-Token")); token != "" {
|
|
if record, ok := r.config.ValidateAPIToken(token); ok {
|
|
snapshotReq := req
|
|
attachAPITokenRecord(snapshotReq, record)
|
|
tokenUsername := apiTokenAuthenticatedUser(record)
|
|
snapshotReq = attachUserContext(snapshotReq, tokenUsername)
|
|
recordClone := record.Clone()
|
|
return securityStatusAuthSnapshot{
|
|
request: snapshotReq,
|
|
authenticated: true,
|
|
authMethod: "api_token",
|
|
username: tokenUsername,
|
|
tokenRecord: &recordClone,
|
|
}
|
|
}
|
|
}
|
|
|
|
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
|
|
username := strings.TrimSpace(GetSessionUsername(cookie.Value))
|
|
snapshotReq := attachUserContext(req, username)
|
|
// Same privilege rule as ensureAdminSession: the configured admin
|
|
// identity or an explicit RBAC admin grant (including SSO group role
|
|
// mappings). Org-scoped sessions keep their own management rules.
|
|
sessionIsAdmin := false
|
|
sessionManagesOrg := false
|
|
if sessionIsOrgScoped(req) {
|
|
sessionManagesOrg = sessionUserHasSettingsAdminPrivileges(r.config, req, username)
|
|
} else {
|
|
sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, username)
|
|
}
|
|
return securityStatusAuthSnapshot{
|
|
request: snapshotReq,
|
|
authenticated: true,
|
|
authMethod: "session",
|
|
username: username,
|
|
sessionIsAdmin: sessionIsAdmin,
|
|
sessionManagesOrg: sessionManagesOrg,
|
|
}
|
|
}
|
|
|
|
return securityStatusAuthSnapshot{}
|
|
}
|
|
|
|
func (r *Router) canAccessPermissionSurface(snapshot securityStatusAuthSnapshot, action, resource string, scopes ...string) bool {
|
|
if !snapshot.authenticated || snapshot.request == nil {
|
|
return false
|
|
}
|
|
|
|
// Without a real RBAC authorizer, Authorize allows every action, so it
|
|
// cannot be the sole input to a capability. The routes these capabilities
|
|
// describe are still gated, by ensureSettingsScope and in turn
|
|
// ensureAdminSession, so reporting the capability from the authorizer alone
|
|
// advertises a surface the caller will be refused: the tab renders and its
|
|
// first request comes back 403. Fall back to the same admin identity the
|
|
// routes enforce, which snapshot.sessionIsAdmin already derives from
|
|
// sessionUserCarriesAdminPrivileges. Only the proxy half of this rule was
|
|
// ever written, so session and SSO callers were told they could reach API
|
|
// token management and SSO provider configuration when they could not.
|
|
if _, isDefaultAuthorizer := r.authorizer.(*internalauth.DefaultAuthorizer); isDefaultAuthorizer {
|
|
switch snapshot.authMethod {
|
|
case "proxy":
|
|
if !snapshot.proxyIsAdmin {
|
|
return false
|
|
}
|
|
case "session":
|
|
if !snapshot.sessionIsAdmin {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
allowed, err := r.authorizer.Authorize(snapshot.request.Context(), action, resource)
|
|
if err != nil || !allowed {
|
|
return false
|
|
}
|
|
|
|
return snapshot.hasScopes(scopes...)
|
|
}
|
|
|
|
func (r *Router) canAccessPlatformAdminSurface(snapshot securityStatusAuthSnapshot) bool {
|
|
if !snapshot.authenticated {
|
|
return false
|
|
}
|
|
|
|
switch snapshot.authMethod {
|
|
case "bypass":
|
|
return true
|
|
case "session":
|
|
return snapshot.sessionIsAdmin
|
|
case "proxy":
|
|
return snapshot.proxyIsAdmin
|
|
case "api_token":
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (r *Router) securityStatusSettingsCapabilitiesFromSnapshot(snapshot securityStatusAuthSnapshot) securityStatusSettingsCapabilities {
|
|
if !snapshot.authenticated {
|
|
return securityStatusSettingsCapabilities{}
|
|
}
|
|
|
|
canAdminSettings := snapshot.canAccessAdminSurface(config.ScopeSettingsRead, config.ScopeSettingsWrite)
|
|
canReadSettings := snapshot.canAccessAdminSurface(config.ScopeSettingsRead)
|
|
canManageUsers := r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers)
|
|
canReadAudit := snapshot.passesPrivilegedSessionGate() &&
|
|
r.canAccessPermissionSurface(snapshot, internalauth.ActionRead, internalauth.ResourceAuditLogs, config.ScopeAuditRead)
|
|
canManageRoles := snapshot.passesPrivilegedSessionGate() && canManageUsers
|
|
canReadPulseIntelligence := canReadSettings &&
|
|
r.canAccessPermissionSurface(snapshot, internalauth.ActionRead, internalauth.ResourceSettings, config.ScopeSettingsRead)
|
|
canReadReporting := canReadSettings &&
|
|
r.canAccessPermissionSurface(snapshot, internalauth.ActionRead, internalauth.ResourceNodes, config.ScopeSettingsRead)
|
|
|
|
// Infrastructure, availability, and reporting are org-bound: their routes
|
|
// use RequireOrgAdmin / ensureOrgAdminSession, which admit an org manager
|
|
// inside their own organization, and their handlers read that
|
|
// organization's config, monitor state and metrics store. A provider
|
|
// opening a client workspace lands there as the org owner, and without
|
|
// these the settings navigation hides the pages that connect the client's
|
|
// systems and build its reports. Reporting also needs read:nodes, which
|
|
// RequirePermission checks before the session rule. Every other capability
|
|
// stays instance administration.
|
|
canReadOrgSettings := canReadSettings || snapshot.sessionManagesOrg
|
|
if snapshot.sessionManagesOrg && !canReadReporting {
|
|
allowed, err := r.authorizer.Authorize(snapshot.request.Context(), internalauth.ActionRead, internalauth.ResourceNodes)
|
|
canReadReporting = err == nil && allowed
|
|
}
|
|
|
|
return securityStatusSettingsCapabilities{
|
|
InfrastructureRead: canReadOrgSettings,
|
|
AvailabilityRead: canReadOrgSettings,
|
|
PulseIntelligenceRead: canReadPulseIntelligence,
|
|
DiagnosticsRead: canReadSettings,
|
|
SystemLogsRead: canReadSettings,
|
|
ReportingRead: canReadReporting,
|
|
SystemSettingsRead: canReadSettings,
|
|
PatrolRun: r.securityStatusCanRunPatrol(snapshot),
|
|
APIAccessRead: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsRead),
|
|
APIAccessWrite: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsWrite),
|
|
AuthenticationRead: canReadSettings,
|
|
AuthenticationWrite: canAdminSettings,
|
|
SingleSignOnRead: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsRead),
|
|
SingleSignOnWrite: r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceUsers, config.ScopeSettingsWrite),
|
|
Roles: canManageRoles,
|
|
Users: canManageRoles,
|
|
AuditLog: canReadAudit,
|
|
AuditWebhooksRead: snapshot.passesPrivilegedSessionGate() && r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceAuditLogs, config.ScopeSettingsRead),
|
|
AuditWebhooksWrite: snapshot.passesPrivilegedSessionGate() && r.canAccessPermissionSurface(snapshot, internalauth.ActionAdmin, internalauth.ResourceAuditLogs, config.ScopeSettingsWrite),
|
|
RelayRead: canReadSettings,
|
|
RelayWrite: canAdminSettings,
|
|
BillingAdmin: r.canAccessPlatformAdminSurface(snapshot),
|
|
}
|
|
}
|
|
|
|
// securityStatusCanRunPatrol answers the RequireAdmin + ai:execute gate on
|
|
// POST /api/ai/patrol/run from the security status snapshot, reading
|
|
// credentials the way every other admin capability does, so it validates no
|
|
// token and has no side effect. It departs from canAccessAdminSurface where
|
|
// ensureAdminSession disagrees with that helper in ways a browser meets: an
|
|
// instance administrator on an org-scoped session is admitted, an
|
|
// organization-bound token is refused whatever its scopes, and a session cookie
|
|
// riding along with a token must belong to an instance administrator. The
|
|
// snapshot's remaining gaps (recovery-session binding, proxy-over-token
|
|
// precedence, Bearer-only tokens, the dev bypass beside an explicit token) are
|
|
// shared with every settings capability.
|
|
func (r *Router) securityStatusCanRunPatrol(snapshot securityStatusAuthSnapshot) bool {
|
|
if r == nil || r.config == nil || !snapshot.authenticated {
|
|
return false
|
|
}
|
|
switch snapshot.authMethod {
|
|
case "session":
|
|
return sessionUserCarriesAdminPrivileges(r.config, snapshot.username)
|
|
case "api_token":
|
|
if !tokenActsForInstance(snapshot.tokenRecord) || !snapshot.hasScopes(config.ScopeAIExecute) {
|
|
return false
|
|
}
|
|
if snapshot.request != nil {
|
|
if cookie, err := readSessionCookie(snapshot.request); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
|
|
return sessionUserCarriesAdminPrivileges(r.config, strings.TrimSpace(GetSessionUsername(cookie.Value)))
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return snapshot.canAccessAdminSurface(config.ScopeAIExecute)
|
|
}
|
|
|
|
func (r *Router) securityStatusSessionCapabilities(ctx context.Context) securityStatusSessionCapabilities {
|
|
demoMode := r != nil && r.config != nil && r.config.DemoMode
|
|
assistantEnabled := false
|
|
if r != nil && r.aiSettingsHandler != nil {
|
|
assistantEnabled = r.aiSettingsHandler.AssistantEnabled(ctx)
|
|
}
|
|
return securityStatusSessionCapabilities{
|
|
DemoMode: demoMode,
|
|
AssistantEnabled: assistantEnabled,
|
|
}
|
|
}
|
|
|
|
// resolveSecurityStatusPresentationPolicy maps commercial suppression inputs
|
|
// to the served policy. Ordinary free self-hosted sessions stay opt-in and do
|
|
// not receive upgrade prompts. Hosted sessions and installs with existing paid
|
|
// or recovery context may expose the commercial navigation; a compiled
|
|
// Pro-edition binary counts as paid context because it is only distributed
|
|
// through the paid broker flow, and hiding Plans & Billing from it strands a
|
|
// customer with no way to find the activation form. Demo mode and white-label
|
|
// runtimes remain fully suppressed.
|
|
func resolveSecurityStatusPresentationPolicy(demoMode, whiteLabel, commercialContext bool) securityStatusPresentationPolicy {
|
|
hideCommercial := demoMode || whiteLabel
|
|
return securityStatusPresentationPolicy{
|
|
DemoMode: demoMode,
|
|
ReadOnly: demoMode,
|
|
HideCommercial: hideCommercial,
|
|
HideUpgrade: hideCommercial || !commercialContext,
|
|
}
|
|
}
|
|
|
|
func (r *Router) securityStatusPresentationPolicy(ctx context.Context) securityStatusPresentationPolicy {
|
|
demoMode := r != nil && r.config != nil && r.config.DemoMode
|
|
whiteLabel := false
|
|
commercialContext := r != nil && r.hostedMode
|
|
if edition.IsPro() {
|
|
// The compiled Pro binary reaches an install only through the paid
|
|
// broker flow, so the session was never an ordinary free self-hosted
|
|
// one — even before its license activates. Without this, the only
|
|
// navigation entry holding the activation form is hidden exactly
|
|
// while the install is unlicensed. Community binaries with or
|
|
// without a license key are unaffected.
|
|
commercialContext = true
|
|
}
|
|
if r != nil && !demoMode {
|
|
if svc := getLicenseServiceForContext(ctx); svc != nil {
|
|
whiteLabel = svc.HasFeature(featureWhiteLabelValue)
|
|
if lic := svc.Current(); lic != nil && lic.Claims.Tier != licenseTierFreeValue {
|
|
commercialContext = true
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(svc.SubscriptionState())) {
|
|
case "active", "grace", "canceled", "suspended", "trial":
|
|
commercialContext = true
|
|
}
|
|
}
|
|
}
|
|
return resolveSecurityStatusPresentationPolicy(demoMode, whiteLabel, commercialContext)
|
|
}
|