diff --git a/internal/mcp/apps.go b/internal/mcp/apps.go index 4cb10ec..8dc5784 100644 --- a/internal/mcp/apps.go +++ b/internal/mcp/apps.go @@ -16,6 +16,9 @@ import ( //go:embed apps_html/dashboard.html var dashboardHTML string +//go:embed apps_html/compliance.html +var complianceHTML string + //go:embed apps_html/common.js var appCommonJS string @@ -67,6 +70,17 @@ var appSpecs = []appSpec{ return fetchDashboardData(ctx) }, }, + { + Name: "compliance_view", + Description: "Show a JumpCloud compliance snapshot scoped to audit-friendly metrics: MFA adoption (% enrolled + list of users without MFA), device encryption (% FDE-enabled, segmented by OS, with unencrypted-device drill-down), password-age histogram (<30d / 30-60d / 60-90d / >90d), and admin inventory (per-admin email, role, MFA status, last login). Renders as a 4-card report in MCP App-capable hosts; returns the same data as JSON when rendering isn't supported.", + ResourceURI: "ui://jc/compliance", + ResourceName: "Compliance Snapshot App", + ResourceDescription: "Audit-friendly JumpCloud compliance snapshot (MFA, FDE, password age, admins)", + HTML: complianceHTML, + Handler: func(ctx context.Context) (any, error) { + return fetchComplianceData(ctx) + }, + }, } // renderAppHTML returns the app's HTML with the common.js scaffolding injected diff --git a/internal/mcp/apps_compliance.go b/internal/mcp/apps_compliance.go new file mode 100644 index 0000000..731908a --- /dev/null +++ b/internal/mcp/apps_compliance.go @@ -0,0 +1,421 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "sync" + "time" + + "github.com/klaassen-consulting/jc/internal/api" +) + +// Compliance-view tuning constants. The "list of offenders" panels are +// bounded so a large org doesn't push a 50,000-row payload through the +// iframe; the percentages still aggregate across the full population. +const ( + complianceListLimit = 200 + + // Password-age histogram thresholds in days. Bucket boundaries are + // exclusive upper bounds; the >90 bucket catches anything stale-er + // (or any password_date in the future, defensively). + complianceAgeLT30 = 30 + complianceAgeLT60 = 60 + complianceAgeLT90 = 90 +) + +// complianceData is the JSON payload pushed to the compliance.html +// iframe. Mirrors the structured-per-card pattern from the dashboard +// and device_view: top-level sections, a Warnings slice for partial +// failures, and a timestamp the UI can show as "snapshot taken at". +type complianceData struct { + MFA *complianceMFA `json:"mfa,omitempty"` + FDE *complianceFDE `json:"fde,omitempty"` + Passwords *compliancePassword `json:"passwords,omitempty"` + Admins *complianceAdmins `json:"admins,omitempty"` + Timestamp string `json:"timestamp"` + Warnings []string `json:"warnings,omitempty"` +} + +type complianceMFA struct { + Total int `json:"total"` + Enrolled int `json:"enrolled"` + Percentage float64 `json:"percentage"` + WithoutMFA []complianceUserRef `json:"without_mfa"` + WithoutMFALen int `json:"without_mfa_total"` // total before truncation +} + +type complianceUserRef struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` +} + +type complianceFDE struct { + Total int `json:"total"` + Encrypted int `json:"encrypted"` + Percentage float64 `json:"percentage"` + ByOS []complianceFDEByOS `json:"by_os"` + Unencrypted []complianceDeviceRef `json:"unencrypted"` + // UnencryptedLen lets the UI show "showing N of M" when the list is + // truncated. complianceListLimit-bounded. + UnencryptedLen int `json:"unencrypted_total"` +} + +type complianceFDEByOS struct { + OS string `json:"os"` + Total int `json:"total"` + Encrypted int `json:"encrypted"` + Percentage float64 `json:"percentage"` +} + +type complianceDeviceRef struct { + ID string `json:"id"` + Hostname string `json:"hostname,omitempty"` + OS string `json:"os,omitempty"` +} + +type compliancePassword struct { + // Total counts users with a usable password_date — users with no + // recorded password date are surfaced separately as NoData so the + // histogram percentages aren't skewed by missing data. + Total int `json:"total"` + NoData int `json:"no_data"` + Buckets []compliancePasswordBucket `json:"buckets"` +} + +type compliancePasswordBucket struct { + // Label is human-readable (e.g. "<30d", "30-60d"). Index follows + // the ordering of complianceAgeLT* constants so the UI can render + // left-to-right without lookups. + Label string `json:"label"` + Count int `json:"count"` + Percentage float64 `json:"percentage"` +} + +type complianceAdmins struct { + Total int `json:"total"` + List []complianceAdminRef `json:"list"` +} + +type complianceAdminRef struct { + ID string `json:"id"` + Email string `json:"email"` + RoleName string `json:"role_name,omitempty"` + EnableMultiFactor bool `json:"enable_multi_factor"` + LastLogin string `json:"last_login,omitempty"` +} + +// fetchComplianceData runs the parallel API calls behind compliance_view +// and aggregates the result. Same best-effort contract as device_view: +// a transient sub-fetch failure surfaces as a Warning rather than +// blocking the whole snapshot. +func fetchComplianceData(ctx context.Context) (*complianceData, error) { + now := nowFunc().UTC() + data := &complianceData{Timestamp: now.Format(time.RFC3339)} + + var ( + mu sync.Mutex + warnings []string + ) + addWarning := func(msg string) { + mu.Lock() + warnings = append(warnings, msg) + mu.Unlock() + } + + var wg sync.WaitGroup + + // V1: org users → MFA + password age. One scan, two derived stats. + wg.Add(1) + go func() { + defer wg.Done() + v1, err := newV1ClientFunc() + if err != nil { + addWarning(fmt.Sprintf("v1 client (users): %v", err)) + return + } + result, err := v1.ListAll(ctx, "/systemusers", api.ListOptions{}) + if err != nil { + addWarning(fmt.Sprintf("listing users: %v", err)) + return + } + mfa, pwd := aggregateUserCompliance(result.Data, now) + mu.Lock() + data.MFA = mfa + data.Passwords = pwd + mu.Unlock() + }() + + // V1: devices → FDE coverage segmented by OS, with an unencrypted + // drill-down list. + wg.Add(1) + go func() { + defer wg.Done() + v1, err := newV1ClientFunc() + if err != nil { + addWarning(fmt.Sprintf("v1 client (devices): %v", err)) + return + } + result, err := v1.ListAll(ctx, "/systems", api.ListOptions{}) + if err != nil { + addWarning(fmt.Sprintf("listing devices: %v", err)) + return + } + fde := aggregateDeviceCompliance(result.Data) + mu.Lock() + data.FDE = fde + mu.Unlock() + }() + + // V1: admins live on the V1 /users endpoint (distinct from + // /systemusers, which is org members). One snapshot pulls every + // admin row — orgs typically have single-digit admins. + wg.Add(1) + go func() { + defer wg.Done() + v1, err := newV1ClientFunc() + if err != nil { + addWarning(fmt.Sprintf("v1 client (admins): %v", err)) + return + } + result, err := v1.ListAll(ctx, "/users", api.ListOptions{}) + if err != nil { + addWarning(fmt.Sprintf("listing admins: %v", err)) + return + } + admins := aggregateAdmins(result.Data) + mu.Lock() + data.Admins = admins + mu.Unlock() + }() + + wg.Wait() + + if len(warnings) > 0 { + data.Warnings = warnings + } + + // If every section failed, the snapshot is useless — return an + // error so the chokepoint surfaces the failure clearly rather than + // shipping an empty card. + if data.MFA == nil && data.FDE == nil && data.Admins == nil { + return nil, fmt.Errorf("all compliance sub-fetches failed: %v", warnings) + } + + return data, nil +} + +// aggregateUserCompliance derives MFA adoption + password-age histogram +// from a single users list scan. Splitting the two aggregations into +// separate goroutines would double the V1 page count for no gain. +func aggregateUserCompliance(data []json.RawMessage, now time.Time) (*complianceMFA, *compliancePassword) { + mfa := &complianceMFA{} + pwd := &compliancePassword{} + + bucketCounts := [4]int{} // <30, 30-60, 60-90, >90 + + for _, raw := range data { + var u struct { + ID string `json:"_id"` + Username string `json:"username"` + Email string `json:"email"` + Activated bool `json:"activated"` + Suspended bool `json:"suspended"` + AccountLocked bool `json:"account_locked"` + TOTPEnabled bool `json:"totp_enabled"` + MFA struct { + Configured bool `json:"configured"` + } `json:"mfa"` + PasswordDate string `json:"password_date"` + } + if err := json.Unmarshal(raw, &u); err != nil { + continue + } + + // MFA scope: only count active, non-suspended, non-locked users. + // A locked user without MFA isn't a compliance concern; an + // active one without MFA is. + if u.Activated && !u.Suspended && !u.AccountLocked { + mfa.Total++ + if u.TOTPEnabled || u.MFA.Configured { + mfa.Enrolled++ + } else if len(mfa.WithoutMFA) < complianceListLimit { + mfa.WithoutMFA = append(mfa.WithoutMFA, complianceUserRef{ + ID: u.ID, Username: u.Username, Email: u.Email, + }) + mfa.WithoutMFALen++ + } else { + mfa.WithoutMFALen++ + } + } + + // Password age: bucket by days since password_date. Empty or + // unparsable dates land in NoData so the histogram only + // counts users with real data. + if u.PasswordDate == "" { + pwd.NoData++ + continue + } + t, err := time.Parse(time.RFC3339, u.PasswordDate) + if err != nil { + // Some orgs store password_date as date-only ("2026-01-01"); + // try that shape too before giving up. + t, err = time.Parse("2006-01-02", u.PasswordDate) + } + if err != nil { + pwd.NoData++ + continue + } + days := int(now.Sub(t).Hours() / 24) + switch { + case days < 0: + // Future password_date: clock skew, API timestamp oddity, + // or a tenant that records the *expiration* date instead + // of the set date. Route to >90d so the anomaly surfaces + // in the suspicious bucket rather than inflating the + // healthy <30d bucket. Comment on complianceAgeLT* says + // the bucket "catches anything stale-er (or any + // password_date in the future, defensively)" — this is + // the route that delivers on that promise. + bucketCounts[3]++ + case days < complianceAgeLT30: + bucketCounts[0]++ + case days < complianceAgeLT60: + bucketCounts[1]++ + case days < complianceAgeLT90: + bucketCounts[2]++ + default: + bucketCounts[3]++ + } + pwd.Total++ + } + + if mfa.Total > 0 { + mfa.Percentage = float64(mfa.Enrolled) / float64(mfa.Total) * 100 + } + + labels := []string{"<30d", "30-60d", "60-90d", ">90d"} + pwd.Buckets = make([]compliancePasswordBucket, len(labels)) + for i, label := range labels { + count := bucketCounts[i] + var pct float64 + if pwd.Total > 0 { + pct = float64(count) / float64(pwd.Total) * 100 + } + pwd.Buckets[i] = compliancePasswordBucket{ + Label: label, Count: count, Percentage: pct, + } + } + + return mfa, pwd +} + +// aggregateDeviceCompliance derives the FDE coverage stats. Per-OS +// buckets are surfaced because compliance reviews often need "we have +// 100 % FDE on Macs but only 60 % on Windows" — a single org-wide +// percentage hides those gaps. +func aggregateDeviceCompliance(data []json.RawMessage) *complianceFDE { + fde := &complianceFDE{} + + type osStat struct{ total, encrypted int } + byOS := make(map[string]*osStat) + + for _, raw := range data { + var d struct { + ID string `json:"_id"` + Hostname string `json:"hostname"` + OS string `json:"os"` + FDE struct { + Active bool `json:"active"` + } `json:"fde"` + } + if err := json.Unmarshal(raw, &d); err != nil { + continue + } + fde.Total++ + + osName := d.OS + if osName == "" { + osName = "Unknown" + } + stat, ok := byOS[osName] + if !ok { + stat = &osStat{} + byOS[osName] = stat + } + stat.total++ + + if d.FDE.Active { + fde.Encrypted++ + stat.encrypted++ + } else { + if len(fde.Unencrypted) < complianceListLimit { + fde.Unencrypted = append(fde.Unencrypted, complianceDeviceRef{ + ID: d.ID, Hostname: d.Hostname, OS: d.OS, + }) + } + fde.UnencryptedLen++ + } + } + + if fde.Total > 0 { + fde.Percentage = float64(fde.Encrypted) / float64(fde.Total) * 100 + } + + // Stable per-OS slice sorted by total desc so the busiest platforms + // surface first — that's what an auditor scrolls to. + osList := make([]complianceFDEByOS, 0, len(byOS)) + for name, stat := range byOS { + var pct float64 + if stat.total > 0 { + pct = float64(stat.encrypted) / float64(stat.total) * 100 + } + osList = append(osList, complianceFDEByOS{ + OS: name, Total: stat.total, Encrypted: stat.encrypted, Percentage: pct, + }) + } + sort.Slice(osList, func(i, j int) bool { + if osList[i].Total != osList[j].Total { + return osList[i].Total > osList[j].Total + } + return osList[i].OS < osList[j].OS + }) + fde.ByOS = osList + + return fde +} + +// aggregateAdmins compiles the admin inventory from the V1 /users +// response. Orgs typically have single-digit admins; complianceListLimit +// applies defensively in case an org somehow has hundreds. +func aggregateAdmins(data []json.RawMessage) *complianceAdmins { + admins := &complianceAdmins{Total: len(data)} + limit := complianceListLimit + if len(data) < limit { + limit = len(data) + } + for i := 0; i < limit; i++ { + var a struct { + ID string `json:"_id"` + Email string `json:"email"` + RoleName string `json:"roleName"` + EnableMultiFactor bool `json:"enableMultiFactor"` + LastLogin string `json:"lastLogin"` + } + if err := json.Unmarshal(data[i], &a); err != nil { + continue + } + admins.List = append(admins.List, complianceAdminRef{ + ID: a.ID, Email: a.Email, RoleName: a.RoleName, + EnableMultiFactor: a.EnableMultiFactor, LastLogin: a.LastLogin, + }) + } + // Stable alphabetical sort so an audit run-to-run sees the same + // order even when the V1 endpoint shuffles results. + sort.Slice(admins.List, func(i, j int) bool { + return admins.List[i].Email < admins.List[j].Email + }) + return admins +} diff --git a/internal/mcp/apps_compliance_test.go b/internal/mcp/apps_compliance_test.go new file mode 100644 index 0000000..1b693fd --- /dev/null +++ b/internal/mcp/apps_compliance_test.go @@ -0,0 +1,343 @@ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// startComplianceServer mounts the three V1 endpoints fetchComplianceData +// hits (/systemusers, /systems, /users for admins) on a single httptest +// server with deterministic fixtures. +func startComplianceServer(t *testing.T, users, devices, admins []map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/systemusers": + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": users, "totalCount": len(users), + }) + case "/api/systems": + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": devices, "totalCount": len(devices), + }) + case "/api/users": + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": admins, "totalCount": len(admins), + }) + default: + w.WriteHeader(404) + _ = json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path}) + } + })) +} + +// passwordDateDaysAgo formats a date `days` ago using the date-only +// layout — exercises the secondary parse path in aggregateUserCompliance +// alongside the primary RFC3339 path. We anchor "now" in the tests so +// the math stays deterministic. +func passwordDateDaysAgo(now time.Time, days int) string { + return now.Add(-time.Duration(days) * 24 * time.Hour).Format("2006-01-02") +} + +func TestAggregateUserCompliance_MFAAndPasswordBuckets(t *testing.T) { + now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + users := []map[string]any{ + // Active + MFA + 5d password → bucket 0 + {"_id": "u1", "username": "alice", "email": "a@x.com", + "activated": true, "totp_enabled": true, + "password_date": passwordDateDaysAgo(now, 5)}, + // Active + no MFA + 45d password → bucket 1, lands in WithoutMFA + {"_id": "u2", "username": "bob", "email": "b@x.com", + "activated": true, "totp_enabled": false, + "password_date": passwordDateDaysAgo(now, 45)}, + // Active + MFA via mfa.configured + 75d → bucket 2 + {"_id": "u3", "username": "carol", "email": "c@x.com", + "activated": true, "mfa": map[string]any{"configured": true}, + "password_date": passwordDateDaysAgo(now, 75)}, + // Active + no MFA + 120d → bucket 3, second offender + {"_id": "u4", "username": "dave", "email": "d@x.com", + "activated": true, "totp_enabled": false, + "password_date": passwordDateDaysAgo(now, 120)}, + // Suspended user without MFA → excluded from MFA scope but + // still contributes to password buckets (bucket 0). + {"_id": "u5", "username": "evan", "email": "e@x.com", + "activated": true, "suspended": true, "totp_enabled": false, + "password_date": passwordDateDaysAgo(now, 10)}, + // Locked user without MFA → also excluded from MFA scope. + {"_id": "u6", "username": "frank", "email": "f@x.com", + "activated": true, "account_locked": true, "totp_enabled": false, + "password_date": passwordDateDaysAgo(now, 20)}, + // Empty password_date → no_data, still counts toward MFA scope. + {"_id": "u7", "username": "gina", "email": "g@x.com", + "activated": true, "totp_enabled": true}, + } + raws := make([]json.RawMessage, len(users)) + for i, u := range users { + raws[i], _ = json.Marshal(u) + } + + mfa, pwd := aggregateUserCompliance(raws, now) + + // MFA scope: 5 active users (u1, u2, u3, u4, u7); u5 suspended, u6 + // locked — both excluded so an inactive account isn't held against + // the org's MFA percentage. + if mfa.Total != 5 { + t.Errorf("MFA total = %d, want 5 (excludes suspended/locked)", mfa.Total) + } + if mfa.Enrolled != 3 { + t.Errorf("MFA enrolled = %d, want 3 (u1, u3 via mfa.configured, u7)", mfa.Enrolled) + } + // 3 / 5 = 60 % + if mfa.Percentage < 59.9 || mfa.Percentage > 60.1 { + t.Errorf("MFA percentage = %.2f, want ~60.0", mfa.Percentage) + } + // Without MFA: u2 (bob) and u4 (dave). u5/u6 excluded from scope. + if len(mfa.WithoutMFA) != 2 { + t.Fatalf("WithoutMFA = %+v, want 2 entries (bob, dave)", mfa.WithoutMFA) + } + // Order follows iteration order of the input slice, which is + // deterministic for this test. + if mfa.WithoutMFA[0].Username != "bob" || mfa.WithoutMFA[1].Username != "dave" { + t.Errorf("WithoutMFA usernames = %+v, want [bob, dave]", mfa.WithoutMFA) + } + if mfa.WithoutMFALen != 2 { + t.Errorf("WithoutMFALen = %d, want 2", mfa.WithoutMFALen) + } + + // Password buckets: u1=5d, u5=10d, u6=20d → bucket 0 (3 users); + // u2=45d → bucket 1; u3=75d → bucket 2; u4=120d → bucket 3. + // u7 → no_data. + wantBuckets := []int{3, 1, 1, 1} + for i, want := range wantBuckets { + if pwd.Buckets[i].Count != want { + t.Errorf("bucket[%d] %q count = %d, want %d", + i, pwd.Buckets[i].Label, pwd.Buckets[i].Count, want) + } + } + if pwd.NoData != 1 { + t.Errorf("NoData = %d, want 1 (u7)", pwd.NoData) + } + if pwd.Total != 6 { + t.Errorf("password Total = %d, want 6 (excludes no_data)", pwd.Total) + } + if pwd.Buckets[0].Label != "<30d" { + t.Errorf("bucket[0].Label = %q, want \"<30d\"", pwd.Buckets[0].Label) + } +} + +// Bugbot KLA-405 catch: a password_date in the future (clock skew, +// tenant emitting expiration date instead of set date, etc.) must NOT +// inflate the healthy <30d bucket. Route it to >90d instead so the +// anomaly is visible to the auditor. +func TestAggregateUserCompliance_FutureDateRoutesToOver90d(t *testing.T) { + now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + users := []map[string]any{ + // 10 days in the future — clearly anomalous. + {"_id": "u1", "username": "alice", "activated": true, "totp_enabled": true, + "password_date": now.Add(10 * 24 * time.Hour).Format(time.RFC3339)}, + // Sanity-check anchor: a real fresh date stays in <30d. + {"_id": "u2", "username": "bob", "activated": true, "totp_enabled": true, + "password_date": passwordDateDaysAgo(now, 5)}, + } + raws := make([]json.RawMessage, len(users)) + for i, u := range users { + raws[i], _ = json.Marshal(u) + } + + _, pwd := aggregateUserCompliance(raws, now) + + if pwd.Buckets[0].Count != 1 { + t.Errorf("bucket[<30d].Count = %d, want 1 (only bob); future-dated alice should not land here", + pwd.Buckets[0].Count) + } + if pwd.Buckets[3].Count != 1 { + t.Errorf("bucket[>90d].Count = %d, want 1 (alice, future-dated)", + pwd.Buckets[3].Count) + } + if pwd.Total != 2 { + t.Errorf("Total = %d, want 2 (both users counted, neither no_data)", pwd.Total) + } +} + +func TestAggregateDeviceCompliance_FDEBuckets(t *testing.T) { + devices := []map[string]any{ + {"_id": "d1", "hostname": "mac-1", "os": "Mac OS X", "fde": map[string]any{"active": true}}, + {"_id": "d2", "hostname": "mac-2", "os": "Mac OS X", "fde": map[string]any{"active": true}}, + {"_id": "d3", "hostname": "win-1", "os": "Windows", "fde": map[string]any{"active": false}}, + {"_id": "d4", "hostname": "win-2", "os": "Windows", "fde": map[string]any{"active": true}}, + {"_id": "d5", "hostname": "linux-1", "os": "", "fde": map[string]any{"active": false}}, + } + raws := make([]json.RawMessage, len(devices)) + for i, d := range devices { + raws[i], _ = json.Marshal(d) + } + + fde := aggregateDeviceCompliance(raws) + + if fde.Total != 5 || fde.Encrypted != 3 { + t.Errorf("overall = %d/%d, want 3/5", fde.Encrypted, fde.Total) + } + if fde.Percentage < 59.9 || fde.Percentage > 60.1 { + t.Errorf("percentage = %.2f, want 60.0", fde.Percentage) + } + // ByOS sorted by total desc, then alphabetical. Mac=2 and Win=2 + // tie on total, so alphabetical: Mac OS X before Windows. Unknown=1 + // comes after. + if len(fde.ByOS) != 3 { + t.Fatalf("ByOS = %+v, want 3 entries", fde.ByOS) + } + if fde.ByOS[0].OS != "Mac OS X" || fde.ByOS[0].Encrypted != 2 { + t.Errorf("ByOS[0] = %+v, want Mac OS X 2/2", fde.ByOS[0]) + } + if fde.ByOS[1].OS != "Windows" || fde.ByOS[1].Encrypted != 1 { + t.Errorf("ByOS[1] = %+v, want Windows 1/2", fde.ByOS[1]) + } + if fde.ByOS[2].OS != "Unknown" { + t.Errorf("ByOS[2] should be Unknown bucket, got %+v", fde.ByOS[2]) + } + // Unencrypted list: d3 + d5 + if len(fde.Unencrypted) != 2 { + t.Errorf("Unencrypted = %+v, want 2 entries", fde.Unencrypted) + } + if fde.UnencryptedLen != 2 { + t.Errorf("UnencryptedLen = %d, want 2", fde.UnencryptedLen) + } +} + +func TestAggregateAdmins_SortAndShape(t *testing.T) { + admins := []map[string]any{ + {"_id": "a2", "email": "zane@acme.com", "roleName": "Administrator", + "enableMultiFactor": true, "lastLogin": "2026-05-20T10:00:00Z"}, + {"_id": "a1", "email": "alice@acme.com", "roleName": "Read-Only", + "enableMultiFactor": false, "lastLogin": ""}, + } + raws := make([]json.RawMessage, len(admins)) + for i, a := range admins { + raws[i], _ = json.Marshal(a) + } + + got := aggregateAdmins(raws) + if got.Total != 2 { + t.Errorf("total = %d, want 2", got.Total) + } + // Sorted alphabetically by email. + if got.List[0].Email != "alice@acme.com" { + t.Errorf("list[0] = %q, want alice@acme.com (alphabetical sort)", got.List[0].Email) + } + if got.List[1].EnableMultiFactor != true { + t.Errorf("list[1].EnableMultiFactor = %v, want true", got.List[1].EnableMultiFactor) + } +} + +func TestFetchComplianceData_EndToEnd(t *testing.T) { + setupToolTest(t) + + now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC) + origNow := nowFunc + nowFunc = func() time.Time { return now } + t.Cleanup(func() { nowFunc = origNow }) + + users := []map[string]any{ + {"_id": "u1", "username": "alice", "email": "a@x.com", + "activated": true, "totp_enabled": true, + "password_date": passwordDateDaysAgo(now, 5)}, + {"_id": "u2", "username": "bob", "email": "b@x.com", + "activated": true, "totp_enabled": false, + "password_date": passwordDateDaysAgo(now, 100)}, + } + devices := []map[string]any{ + {"_id": "d1", "hostname": "mac-1", "os": "Mac OS X", "fde": map[string]any{"active": true}}, + {"_id": "d2", "hostname": "mac-2", "os": "Mac OS X", "fde": map[string]any{"active": false}}, + } + admins := []map[string]any{ + {"_id": "a1", "email": "admin@acme.com", "roleName": "Administrator", + "enableMultiFactor": true, "lastLogin": "2026-05-20T10:00:00Z"}, + } + + ts := startComplianceServer(t, users, devices, admins) + t.Cleanup(ts.Close) + overrideV1ClientForTest(t, ts.URL) + + data, err := fetchComplianceData(context.Background()) + if err != nil { + t.Fatalf("fetch: %v", err) + } + + if data.MFA == nil || data.FDE == nil || data.Passwords == nil || data.Admins == nil { + t.Fatalf("missing sections: %+v", data) + } + if data.MFA.Total != 2 || data.MFA.Enrolled != 1 { + t.Errorf("MFA = %+v, want 2 active / 1 enrolled", data.MFA) + } + if data.FDE.Total != 2 || data.FDE.Encrypted != 1 { + t.Errorf("FDE = %+v, want 2 total / 1 encrypted", data.FDE) + } + if data.Admins.Total != 1 || data.Admins.List[0].Email != "admin@acme.com" { + t.Errorf("admins = %+v", data.Admins) + } + if data.Timestamp != now.Format(time.RFC3339) { + t.Errorf("timestamp = %q, want %q", data.Timestamp, now.Format(time.RFC3339)) + } +} + +func TestComplianceView_HasUIMetadata(t *testing.T) { + setupToolTest(t) + cs := connectToolTestServer(t, Options{}) + + result, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + var found *mcp.Tool + for _, tool := range result.Tools { + if tool.Name == "compliance_view" { + found = tool + break + } + } + if found == nil { + t.Fatal("compliance_view tool missing") + } + meta := map[string]any(found.Meta) + ui, ok := meta["ui"].(map[string]any) + if !ok { + t.Fatalf("expected _meta.ui to be a map, got %T", meta["ui"]) + } + if uri, _ := ui["resourceUri"].(string); uri != "ui://jc/compliance" { + t.Errorf("resourceUri = %q, want ui://jc/compliance", uri) + } +} + +func TestComplianceViewResource_ServesHTMLWithInjection(t *testing.T) { + setupToolTest(t) + cs := connectToolTestServer(t, Options{}) + + result, err := cs.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "ui://jc/compliance"}) + if err != nil { + t.Fatalf("ReadResource: %v", err) + } + if len(result.Contents) == 0 { + t.Fatal("empty resource contents") + } + c := result.Contents[0] + if c.MIMEType != mcpAppMIMEType { + t.Errorf("MIME = %q, want %q", c.MIMEType, mcpAppMIMEType) + } + if !strings.Contains(c.Text, "window.jcApp") { + t.Error("served HTML missing common.js injection") + } + if strings.Contains(c.Text, appCommonMarker) { + t.Error("served HTML still contains injection marker") + } + if !strings.Contains(c.Text, "JumpCloud Compliance Snapshot") { + t.Error("served HTML missing page title") + } +} diff --git a/internal/mcp/apps_html/compliance.html b/internal/mcp/apps_html/compliance.html new file mode 100644 index 0000000..52ced6e --- /dev/null +++ b/internal/mcp/apps_html/compliance.html @@ -0,0 +1,490 @@ + + +
+ + +