diff --git a/apps/landing/src/content/docs/alerting/alert-rules.md b/apps/landing/src/content/docs/alerting/alert-rules.md index ac1c24e221..a6c21e756d 100644 --- a/apps/landing/src/content/docs/alerting/alert-rules.md +++ b/apps/landing/src/content/docs/alerting/alert-rules.md @@ -203,6 +203,32 @@ The response lists the value and status of each window per group in `series`, an `POST /v2/alerts/rules/test` evaluates a rule once against current data. Set `send_notification` to `true` to also deliver a test notification. See the [API reference](/docs/reference/api) for every endpoint and field. +## Audit log detections + +A **detection** is an alert rule that counts audit events. It fires when a set number of matching events arrive within a window, for example 5 or more failed sign-ins in 10 minutes. It is not one of the query modes above: organization admins create, edit and delete detections under **Security → Detections**, and the alert rules API does not create or change them. + +A detection matches events by **Platform**, **Category** and **Outcome**, each of which can stay on **Any**, and optionally by one exact **Action**, written as the platform names it, such as `team.member.role.updated`. With no filters it counts every audit event. It cannot match on who acted: a detection never names a person. + +| Field | Values | +| -------- | ------------------------------------------- | +| Count | 1 to 100,000 events | +| Window | 5 minutes to 24 hours | +| Severity | Warning or Critical | +| Notify | Up to 20 notification destinations, or none | + +An organization can have up to 50 detections. Each one is checked every minute. + +There are two ways to start one: + +- Open **Security → Detections** and click **New detection**. While the organization has no detections, the page lists five ready-made ones: a member added or removed, a permission or role changed, a credential created, rotated or revoked, a burst of refused requests, and repeated sign-in failures. Picking one fills in the editor. Nothing is created until you save. +- Filter the audit log under **Security → Audit log** and click **Alert on these events**. The editor opens with the platform, category and outcome you filtered by. An actor filter is not carried over. + +When a detection fires it opens an [incident](/docs/alerting/incidents) and notifies its [destinations](/docs/alerting/notification-destinations). The notification carries the number of matching events, never an event or who caused it. A detection with no destination still opens the incident, and nobody is notified. + +Every member of the organization can see a detection's name and that it fired. The events it counted are in the audit log, which only organization admins can read. + +Deleting a detection also deletes the incidents it opened, open ones included. + ## Troubleshooting - **The rule never fires.** Open the rule and look at its **Checks**. **Skipped** checks mean the window had no data or fewer samples than **Minimum samples**. In the editor, the chart says how many windows would be skipped and why. Check the service names and environments against what is arriving. diff --git a/apps/web/src/lab/entries/security.ts b/apps/web/src/lab/entries/security.ts index c9522b655f..21f2134156 100644 --- a/apps/web/src/lab/entries/security.ts +++ b/apps/web/src/lab/entries/security.ts @@ -6,12 +6,18 @@ import * as AuditLogPage from "../../page/security/audit-log/model" import * as AuditLogUpdate from "../../page/security/audit-log/update" import { view as auditLogView } from "../../page/security/audit-log/view" import * as DependenciesPage from "../../page/security/dependencies/model" +import * as DetectionFormPage from "../../page/security/detection-form/model" +import * as DetectionFormUpdate from "../../page/security/detection-form/update" +import { view as detectionFormView } from "../../page/security/detection-form/view" +import * as DetectionsPage from "../../page/security/detections/model" +import * as DetectionsUpdate from "../../page/security/detections/update" +import { view as detectionsView } from "../../page/security/detections/view" import * as DependenciesUpdate from "../../page/security/dependencies/update" import { view as dependenciesView } from "../../page/security/dependencies/view" import * as FindingPage from "../../page/security/finding/model" import * as FindingUpdate from "../../page/security/finding/update" import { breadcrumbsOf as findingCrumbs, view as findingView } from "../../page/security/finding/view" -import { breadcrumbsOf as tabsCrumbs } from "../../page/security/kit/layout" +import { breadcrumbsOf as tabsCrumbs, detectionFormCrumbs } from "../../page/security/kit/layout" import * as RunPage from "../../page/security/run/model" import * as RunUpdate from "../../page/security/run/update" import { breadcrumbsOf as runCrumbs, view as runView } from "../../page/security/run/view" @@ -34,6 +40,8 @@ const Model = Schema.Struct({ finding: Schema.NullOr(FindingPage.Model), telemetry: Schema.NullOr(TelemetryPage.Model), auditLog: Schema.NullOr(AuditLogPage.Model), + detections: Schema.NullOr(DetectionsPage.Model), + detectionForm: Schema.NullOr(DetectionFormPage.Model), dependencies: Schema.NullOr(DependenciesPage.Model), }) type Model = typeof Model.Type @@ -44,6 +52,8 @@ const Message = defineMessageUnion({ GotFindingMessage: { message: FindingPage.Message }, GotTelemetryMessage: { message: TelemetryPage.Message }, GotAuditLogMessage: { message: AuditLogPage.Message }, + GotDetectionsMessage: { message: DetectionsPage.Message }, + GotDetectionFormMessage: { message: DetectionFormPage.Message }, GotDependenciesMessage: { message: DependenciesPage.Message }, }) type Message = typeof Message.Type @@ -71,6 +81,8 @@ const init = (input: typeof Flags.Type) => { finding: null, telemetry: null, auditLog: null, + detections: null, + detectionForm: null, dependencies: null, } switch (built._tag) { @@ -109,6 +121,20 @@ const init = (input: typeof Flags.Type) => { Message.GotAuditLogMessage({ message }), ), } + case "Detections": + return { + model: { ...empty, detections: built.step.model }, + commands: Command.mapMessages(built.step.commands, (message) => + Message.GotDetectionsMessage({ message }), + ), + } + case "DetectionForm": + return { + model: { ...empty, detectionForm: built.step.model }, + commands: Command.mapMessages(built.step.commands, (message) => + Message.GotDetectionFormMessage({ message }), + ), + } case "Dependencies": return { model: { ...empty, dependencies: built.step.model }, @@ -171,6 +197,26 @@ const update = (model: Model, message: Message) => ), } }, + GotDetectionsMessage: ({ message: inner }) => { + if (model.detections === null) return { model } + const next = DetectionsUpdate.update(model.detections, inner, context) + return { + model: { ...model, detections: next.model }, + commands: Command.mapMessages(Arr.filter(next.commands ?? [], isLocalCommand), (child) => + Message.GotDetectionsMessage({ message: child }), + ), + } + }, + GotDetectionFormMessage: ({ message: inner }) => { + if (model.detectionForm === null) return { model } + const next = DetectionFormUpdate.update(model.detectionForm, inner, context) + return { + model: { ...model, detectionForm: next.model }, + commands: Command.mapMessages(Arr.filter(next.commands ?? [], isLocalCommand), (child) => + Message.GotDetectionFormMessage({ message: child }), + ), + } + }, GotDependenciesMessage: ({ message: inner }) => { if (model.dependencies === null) return { model } const next = DependenciesUpdate.update(model.dependencies, inner, context) @@ -224,15 +270,33 @@ const view = (model: Model, h: HtmlBuilder): Html => viewInputs: { context }, toParentMessage: (message) => Message.GotDependenciesMessage({ message }), }) - : model.auditLog !== null + : model.detections !== null ? h.submodel({ - slotId: "security-lab-audit-log", - model: model.auditLog, - view: auditLogView, + slotId: "security-lab-detections", + model: model.detections, + view: detectionsView, viewInputs: { context }, - toParentMessage: (message) => Message.GotAuditLogMessage({ message }), + toParentMessage: (message) => Message.GotDetectionsMessage({ message }), }) - : h.empty + : model.detectionForm !== null + ? h.submodel({ + slotId: "security-lab-detection-form", + model: model.detectionForm, + view: detectionFormView, + viewInputs: { context }, + toParentMessage: (message) => + Message.GotDetectionFormMessage({ message }), + }) + : model.auditLog !== null + ? h.submodel({ + slotId: "security-lab-audit-log", + model: model.auditLog, + view: auditLogView, + viewInputs: { context }, + toParentMessage: (message) => + Message.GotAuditLogMessage({ message }), + }) + : h.empty export const lab = defineShellLab("Security Center", { Model, @@ -256,5 +320,9 @@ export const lab = defineShellLab("Security Center", { ? tabsCrumbs("dependencies") : model.auditLog !== null ? tabsCrumbs("auditLog") - : [], + : model.detections !== null + ? tabsCrumbs("detections") + : model.detectionForm !== null + ? detectionFormCrumbs() + : [], }) diff --git a/apps/web/src/lab/security-detections-fixture.ts b/apps/web/src/lab/security-detections-fixture.ts new file mode 100644 index 0000000000..97186bc158 --- /dev/null +++ b/apps/web/src/lab/security-detections-fixture.ts @@ -0,0 +1,98 @@ +import { + AlertDestinationId, + AlertRuleId, + type AuditDetection, + type AuditDetectionListResponse, +} from "@maple/domain/http" +import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" +import { Array as Arr, Schema } from "effect" + +// Audit log detections for the Security hub's lab and scene tests: the shapes the list has to +// draw (an exact action, no filters at all, one switched off, one that notifies nobody). + +const HOUR = 3_600_000 + +const ruleId = Schema.decodeSync(AlertRuleId) +const destinationId = Schema.decodeSync(AlertDestinationId) + +export const SECURITY_CHANNEL_ID = destinationId("0199b001-0000-7000-8000-000000000001") +export const ON_CALL_ID = destinationId("0199b001-0000-7000-8000-000000000002") + +/** The org's alert destinations, as the live sync delivers them. */ +export const detectionDestinationsOf = (orgId: string, nowMs: number): ReadonlyArray => + Arr.map( + [ + { id: SECURITY_CHANNEL_ID, name: "#security", type: "slack" }, + { id: ON_CALL_ID, name: "Security on-call", type: "pagerduty" }, + ], + (destination) => ({ + ...destination, + org_id: orgId, + enabled: true, + config_json: { summary: destination.name, channelLabel: null }, + last_tested_at: null, + last_test_error: null, + disabled_at: null, + disabled_reason: null, + created_at: new Date(nowMs - 30 * 24 * HOUR).toISOString(), + updated_at: new Date(nowMs - 30 * 24 * HOUR).toISOString(), + }), + ) + +type Seed = Pick & + Partial> + +const SEEDS: ReadonlyArray = [ + { + name: "Role changes on Vercel", + filters: { provider: "vercel", action: "team.member.role.updated" }, + threshold: 1, + windowMinutes: 5, + severity: "warning", + }, + { + name: "Burst of refused requests", + filters: { outcome: "denied" }, + threshold: 10, + windowMinutes: 10, + severity: "critical", + destinationIds: [SECURITY_CHANNEL_ID, ON_CALL_ID], + }, + { + name: "Credential changes on Google Cloud", + filters: { provider: "gcp", category: "credential" }, + threshold: 1, + windowMinutes: 5, + severity: "warning", + destinationIds: [], + }, + { + name: "Repeated sign-in failures", + filters: { category: "authentication", outcome: "failure" }, + threshold: 5, + windowMinutes: 10, + severity: "critical", + enabled: false, + }, + { + name: "Unusual audit volume", + filters: {}, + threshold: 500, + windowMinutes: 60, + severity: "warning", + }, +] + +/** The detections of an org that set a few up, newest first. */ +export const detectionsOf = (nowMs: number): AuditDetectionListResponse => ({ + detections: Arr.map(SEEDS, (seed, index) => ({ + id: ruleId(`0199b002-0000-7000-8000-00000000000${index + 1}`), + enabled: true, + destinationIds: [SECURITY_CHANNEL_ID], + createdAt: nowMs - (index + 1) * 26 * HOUR, + updatedAt: nowMs - (index + 1) * 5 * HOUR, + ...seed, + })), +}) + +export const noDetections: AuditDetectionListResponse = { detections: [] } diff --git a/apps/web/src/lab/security-lab/scenarios.ts b/apps/web/src/lab/security-lab/scenarios.ts index ae544f79c7..d0edde44a0 100644 --- a/apps/web/src/lab/security-lab/scenarios.ts +++ b/apps/web/src/lab/security-lab/scenarios.ts @@ -5,6 +5,7 @@ import { type DependencyFindingsResponse, type DependencyScanRepositoriesResponse, SecurityAuditDisabledError, + AuditDetectionInvalidError, AuditTrailPersistenceError, SecurityAuditForbiddenError, SecurityAuditTargetExistsError, @@ -25,12 +26,17 @@ import * as TabsPage from "../../page/security/tabs/model" import * as TabsUpdate from "../../page/security/tabs/update" import * as AuditLogPage from "../../page/security/audit-log/model" import * as AuditLogUpdate from "../../page/security/audit-log/update" +import * as DetectionFormPage from "../../page/security/detection-form/model" +import * as DetectionFormUpdate from "../../page/security/detection-form/update" +import * as DetectionsPage from "../../page/security/detections/model" +import * as DetectionsUpdate from "../../page/security/detections/update" import * as TelemetryPage from "../../page/security/telemetry/model" import * as TelemetryUpdate from "../../page/security/telemetry/update" import type { Resources } from "../../platform/resources" import { AppRoute } from "../../route" -import type { SecurityDependenciesSearch } from "../../route/search" +import type { SecurityDependenciesSearch, SecurityDetectionNewSearch } from "../../route/search" import * as Field from "@maple/views/ui/field" +import * as Menu from "@maple/views/ui/menu" import * as Select from "@maple/views/ui/select" import * as ToggleGroup from "@maple/views/ui/toggle-group" import { @@ -63,6 +69,12 @@ import { } from "../security-dependencies-fixture" import { emptyExposureOf, exposureOf, runtimesOf } from "../security-telemetry-fixture" import { auditEventsOf, noAuditEvents } from "../security-audit-log-fixture" +import { + detectionDestinationsOf, + detectionsOf, + noDetections, + ON_CALL_ID, +} from "../security-detections-fixture" // Every Security Center state the lab renders, built by sending each page the fixture answers its // own requests would get. API Commands are dropped; dialog and select Commands still run. @@ -97,6 +109,15 @@ export const SCENARIOS = [ "audit-log-filtered-empty", "audit-log-member", "audit-log-error", + "audit-log-filtered", + "detections", + "detections-empty", + "detections-member", + "detections-error", + "detection-new", + "detection-new-template", + "detection-edit", + "detection-error", "runtimes", "dependencies", "dependencies-finding", @@ -371,6 +392,60 @@ const auditLog = ( const auditLogPage = (page: AuditLogPage.EventsPage) => (model: AuditLogPage.Model) => A.CompletedFetchFirstPage({ key: model.firstPage.key ?? "", result: Result.succeed(page) }) +type DetectionsStep = Step +const DT = DetectionsPage.Message + +const grantsOf = (isMember: boolean) => (isMember ? Policy.grantsOf(["org:engineer"]) : Policy.allPermissions) + +/** The Detections page entered as an admin or a member, then sent `answers`. */ +const detections = ( + options: Readonly<{ + isMember?: boolean + answers?: ReadonlyArray<(model: DetectionsPage.Model) => DetectionsPage.Message> + }> = {}, +): DetectionsStep => { + const entered = DetectionsUpdate.enter(context, true) + return fold( + { model: entered.model, commands: entered.commands ?? [] }, + (model, message) => DetectionsUpdate.update(model, message, context), + [ + () => + DT.CompletedReadGrants({ orgId: context.orgId, grants: grantsOf(options.isMember === true) }), + ...(options.answers ?? []), + ], + ) +} + +const detectionsList = + (list: typeof noDetections) => + (model: DetectionsPage.Model): DetectionsPage.Message => + DT.CompletedFetchDetections({ key: model.list.key ?? "", result: Result.succeed(list) }) + +type DetectionFormStep = Step +const DF = DetectionFormPage.Message + +/** The detection editor entered under `search` as an admin, its destinations synced, then sent `answers`. */ +const detectionForm = ( + nowMs: number, + search: SecurityDetectionNewSearch, + answers: ReadonlyArray<(model: DetectionFormPage.Model) => DetectionFormPage.Message> = [], +): DetectionFormStep => { + const entered = DetectionFormUpdate.enter(AppRoute.SecurityDetectionNew({ search }), context, true) + return fold( + { model: entered.model, commands: entered.commands ?? [] }, + (model, message) => DetectionFormUpdate.update(model, message, context), + [ + () => DF.CompletedReadGrants({ orgId: context.orgId, grants: Policy.allPermissions }), + () => + DF.UpdatedDestinations({ + orgId: context.orgId, + rows: [...detectionDestinationsOf(context.orgId, nowMs)], + }), + ...answers, + ], + ) +} + type TelemetryStep = Step const T = TelemetryPage.Message @@ -447,6 +522,8 @@ export type Built = | Readonly<{ _tag: "Finding"; step: FindingStep }> | Readonly<{ _tag: "Telemetry"; step: TelemetryStep }> | Readonly<{ _tag: "AuditLog"; step: AuditLogStep }> + | Readonly<{ _tag: "Detections"; step: DetectionsStep }> + | Readonly<{ _tag: "DetectionForm"; step: DetectionFormStep }> | Readonly<{ _tag: "Dependencies"; step: DependenciesStep }> const overviewRoute = AppRoute.SecurityCode() @@ -620,6 +697,92 @@ export const build = (scenario: Scenario, nowMs: number): Built => { ], }), } + case "audit-log-filtered": + return { + _tag: "AuditLog", + step: auditLog(nowMs, { + search: { provider: "vercel", category: "credential" }, + answers: [ + auditLogPage({ + events: Arr.filter( + auditEventsOf(nowMs).events, + (event) => event.provider === "vercel" && event.category === "credential", + ), + nextCursor: null, + }), + ], + }), + } + case "detections": + return { + _tag: "Detections", + step: detections({ answers: [detectionsList(detectionsOf(nowMs))] }), + } + case "detections-empty": + return { _tag: "Detections", step: detections({ answers: [detectionsList(noDetections)] }) } + case "detections-member": + return { _tag: "Detections", step: detections({ isMember: true }) } + case "detections-error": + return { + _tag: "Detections", + step: detections({ + answers: [ + (model) => + DT.CompletedFetchDetections({ + key: model.list.key ?? "", + result: Result.fail( + toLoadFailure( + new AuditTrailPersistenceError({ + message: "The audit trail could not be read", + }), + ), + ), + }), + ], + }), + } + case "detection-new": + return { _tag: "DetectionForm", step: detectionForm(nowMs, {}) } + case "detection-new-template": + return { _tag: "DetectionForm", step: detectionForm(nowMs, { template: "sign_in" }) } + case "detection-edit": { + const list = detectionsOf(nowMs) + return { + _tag: "DetectionForm", + step: detectionForm(nowMs, { detectionId: list.detections[1]?.id }, [ + (model) => + DF.CompletedFetchExisting({ + key: model.existing.key ?? "", + result: Result.succeed(list), + }), + ]), + } + } + case "detection-error": + return { + _tag: "DetectionForm", + step: detectionForm(nowMs, { template: "refused" }, [ + () => + DF.GotDestinationMenuMessage({ + message: Menu.Message.ClickedItem({ + index: 1, + item: { kind: "Item", value: ON_CALL_ID }, + }), + }), + () => DF.ClickedSave(), + () => + DF.CompletedSave({ + result: Result.fail( + toLoadFailure( + new AuditDetectionInvalidError({ + message: + 'Another alert rule is already named "Burst of refused requests".', + }), + ), + ), + }), + ]), + } case "runtimes": return { _tag: "Telemetry", diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 61e931d694..1695fe2545 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -40,6 +40,8 @@ import * as DashboardView from "./dashboards/view" import * as DashboardWidget from "./dashboards/widget" import * as CodeReview from "./code-review" import * as SecurityAuditLog from "./security/audit-log" +import * as SecurityDetectionNew from "./security/detection-form" +import * as SecurityDetections from "./security/detections" import * as SecurityDependencies from "./security/dependencies" import * as SecurityFinding from "./security/finding" import * as SecurityRun from "./security/run" @@ -176,6 +178,8 @@ export const pages = [ SecurityTelemetry.page, SecurityDependencies.page, SecurityAuditLog.page, + SecurityDetections.page, + SecurityDetectionNew.page, SecurityRun.page, SecurityFinding.page, AgentSessionsList.page, diff --git a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts index 502fa30e70..678b181073 100644 --- a/apps/web/src/page/security/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/security/audit-log/audit-log.scene.test.ts @@ -342,6 +342,49 @@ describe("security audit log", () => { ) }) + const alertLink = role("link", { name: "Alert on these events" }) + + test("the filters on screen open the detection editor prefilled with them", () => { + scene( + { update: pageUpdate, view }, + given(loaded(PAGE, { provider: "vercel", category: "credential", outcome: "denied" })), + expectView(alertLink).toHaveAttr( + "href", + "/security/detections/new?provider=vercel&category=credential&outcome=denied", + ), + ) + }) + + // A detection cannot name a person: the actor is left out, and the control says it counts everyone. + test("an actor filter is not carried into a detection, and the control says so", () => { + scene( + { update: pageUpdate, view }, + given(loaded(PAGE, { outcome: "denied", actor: "ada@acme.test" })), + expectView(alertLink).toBeAbsent(), + expectView(role("link", { name: "Alert on these events, from any actor" })).toHaveAttr( + "href", + "/security/detections/new?outcome=denied", + ), + ) + }) + + test("with no filter a detection can hold, there is nothing to alert on from here", () => { + const anyAlertLink = role("link", { name: /Alert on these events/u }) + + scene({ update: pageUpdate, view }, given(loaded()), expectView(anyAlertLink).toBeAbsent()) + scene( + { update: pageUpdate, view }, + given(loaded(PAGE, { actor: "ada@acme.test" })), + expectView(anyAlertLink).toBeAbsent(), + ) + // A value the detection schema has no name for cannot be counted either. + scene( + { update: pageUpdate, view }, + given(loaded(PAGE, { provider: "github" })), + expectView(anyAlertLink).toBeAbsent(), + ) + }) + test("a Maple event's id opens the same entry in the Settings audit log", () => { const { event, opened } = openedFrom("maple") diff --git a/apps/web/src/page/security/audit-log/derive.ts b/apps/web/src/page/security/audit-log/derive.ts index 5235d8fbed..ea760153cb 100644 --- a/apps/web/src/page/security/audit-log/derive.ts +++ b/apps/web/src/page/security/audit-log/derive.ts @@ -1,44 +1,14 @@ -import type { AuditTrailCategory, AuditTrailOutcome, AuditTrailProvider } from "@maple/domain/audit-trail" +import { AuditTrailCategory, AuditTrailOutcome, AuditTrailProvider } from "@maple/domain/audit-trail" import type { AuditTrailEntry } from "@maple/domain/http" import type { LoadFailure } from "@maple/views/data/load-failure" -import { Array as Arr, Option, Record as Rec } from "effect" +import { Array as Arr, Option, Schema } from "effect" import { AsyncData } from "foldkit" +import { labelIn } from "../kit/audit-labels" +import { detectionFormHref } from "../kit/detection-format" import type { Model, Search } from "./model" -// What the audit log shows of an event: the names of platforms, categories and outcomes, who acted -// and on what. A value this build does not know shows as itself. - -export const PROVIDER_LABEL = { - maple: "Maple", - vercel: "Vercel", - gcp: "Google Cloud", -} satisfies Record - -export const CATEGORY_LABEL = { - authentication: "Authentication", - membership: "Membership", - permission: "Permissions", - credential: "Credentials", - configuration: "Configuration", - resource: "Resources", - data_access: "Data access", - billing: "Billing", - other: "Other", -} satisfies Record - -export const OUTCOME_LABEL = { - success: "Succeeded", - failure: "Failed", - denied: "Denied", - unknown: "Unknown", -} satisfies Record - -/** The outcomes worth filtering by. `unknown` is a platform that did not say, not something to look for. */ -export const OUTCOME_FILTERS = [ - "success", - "failure", - "denied", -] as const satisfies ReadonlyArray +// What the audit log shows of an event: who acted and on what. The names of platforms, categories +// and outcomes are in `../kit/audit-labels`. const ACTOR_TYPE_LABEL = { user: "User", @@ -47,13 +17,6 @@ const ACTOR_TYPE_LABEL = { unknown: "Unknown actor", } as const -/** The label for `value`, or the value itself when this build has no name for it. */ -export const labelIn = (labels: Readonly>, value: string): string => - Option.getOrElse(Rec.get(labels, value), () => value) - -export const providerLabel = (provider: string): string => labelIn(PROVIDER_LABEL, provider) -export const categoryLabel = (category: string): string => labelIn(CATEGORY_LABEL, category) -export const outcomeLabel = (outcome: string): string => labelIn(OUTCOME_LABEL, outcome) export const actorTypeLabel = (type: string): string => labelIn(ACTOR_TYPE_LABEL, type) /** Who acted: their name, else email, else id, else what kind of actor it was. */ @@ -76,6 +39,23 @@ export const isFiltered = (search: Search): boolean => search.outcome !== undefined || search.actor !== undefined +const isProvider = Schema.is(AuditTrailProvider) +const isCategory = Schema.is(AuditTrailCategory) +const isOutcome = Schema.is(AuditTrailOutcome) + +/** + * The detection editor prefilled with the filters a detection can hold: the platform, category + * and outcome. Never the actor: a detection cannot name a person. Null when none of them is set. + */ +export const alertHref = (search: Search): string | null => { + const provider = isProvider(search.provider) ? search.provider : undefined + const category = isCategory(search.category) ? search.category : undefined + const outcome = isOutcome(search.outcome) ? search.outcome : undefined + return provider === undefined && category === undefined && outcome === undefined + ? null + : detectionFormHref({ provider, category, outcome }) +} + /** The API refused the viewer: the same thing the session says for a member without the permission. */ export const isForbidden = (failure: LoadFailure): boolean => failure.code === "audit_trail_forbidden" diff --git a/apps/web/src/page/security/audit-log/model.ts b/apps/web/src/page/security/audit-log/model.ts index 70e7fc450b..56bc60e3ee 100644 --- a/apps/web/src/page/security/audit-log/model.ts +++ b/apps/web/src/page/security/audit-log/model.ts @@ -43,16 +43,7 @@ export const Model = Schema.Struct({ }) export type Model = typeof Model.Type -/** - * What the session says: `denied` only when it answered and the permission is missing. A session - * that answered with nothing failed to read, so the API is asked and gets the last word. - */ -export const accessOf = (model: Pick): "unknown" | "ask" | "denied" => - !model.isGrantsRead - ? "unknown" - : model.grants.length === 0 || Policy.has(model.grants, "audit_log:read") - ? "ask" - : "denied" +export { auditAccessOf as accessOf } from "../kit/audit-access" export const Message = defineMessageUnion({ ReadClock: { nowMs: Schema.Finite }, diff --git a/apps/web/src/page/security/audit-log/view.ts b/apps/web/src/page/security/audit-log/view.ts index 7a82abf7fd..39e266d0b8 100644 --- a/apps/web/src/page/security/audit-log/view.ts +++ b/apps/web/src/page/security/audit-log/view.ts @@ -7,7 +7,7 @@ import * as Keyed from "@maple/views/data/keyed" import { Array as Arr } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { button } from "@maple/views/ui/button" +import { button, linkButton } from "@maple/views/ui/button" import { compactSearchInput } from "@maple/views/ui/compact-search-input" import { empty, @@ -51,22 +51,25 @@ import { relativeTime } from "../../kit/relative-time" import { ROOMY_COLUMN, securityLayout, WIDE_COLUMN } from "../kit/layout" import { placeholderRows } from "../kit/parts" import { - actorDetail, - actorText, - actorTypeLabel, CATEGORY_LABEL, categoryLabel, - eventsOf, - isFiltered, - isForbidden, - isMarked, labelIn, - nextCursorOf, OUTCOME_FILTERS, OUTCOME_LABEL, outcomeLabel, PROVIDER_LABEL, providerLabel, +} from "../kit/audit-labels" +import { + actorDetail, + actorText, + actorTypeLabel, + alertHref, + eventsOf, + isFiltered, + isForbidden, + isMarked, + nextCursorOf, targetText, } from "./derive" import { accessOf, Message, type Model, type SelectFilter } from "./model" @@ -107,6 +110,30 @@ const filterSelect = ( ], ) +/** + * The filters on screen as a detection. With an actor applied the label says the detection will + * count everyone: it holds no actor, and must not read as if it did. + */ +const alertControl = (h: H, model: Model): Html | undefined => { + const href = alertHref(model.search) + return href === null + ? undefined + : linkButton(h, { variant: "outline", size: "sm", href }, [ + icon(h, "BellIcon"), + // One text run, so the button's gap does not fall before the comma. A phone has no + // room to show the rest, so there it is read out but not drawn. + h.span( + [], + [ + "Alert on these events", + ...(model.search.actor === undefined + ? [] + : [h.span([h.Class("sr-only sm:not-sr-only")], [", from any actor"])]), + ], + ), + ]) +} + const toolbar = (h: H, model: Model): Html => hstack(h, { gap: "2", align: "center", wrap: true, className: "border-b px-4 py-2.5" }, [ filterSelect( @@ -476,19 +503,16 @@ const isRefused = (model: Model): boolean => export const view = Submodel.defineView((model, inputs, h) => { const clock = { nowMs: model.nowMs, timeZone: inputs.context.timeZone } + if (accessOf(model) === "unknown") + return securityLayout(h, { source: "auditLog" }, [placeholderRows(h, 6)]) + if (isRefused(model)) return securityLayout(h, { source: "auditLog" }, [adminsOnly(h)]) return securityLayout( h, - { source: "auditLog" }, - accessOf(model) === "unknown" - ? [placeholderRows(h, 6)] - : isRefused(model) - ? [adminsOnly(h)] - : [ - panel(h, { className: "overflow-hidden" }, [ - toolbar(h, model), - listBody(h, model, clock), - ]), - footnote(h), - ], + // In the header, beside the title: the toolbar is full at laptop width and would wrap it. + { source: "auditLog", actions: alertControl(h, model) }, + [ + panel(h, { className: "overflow-hidden" }, [toolbar(h, model), listBody(h, model, clock)]), + footnote(h), + ], ) }) diff --git a/apps/web/src/page/security/detection-form/command.ts b/apps/web/src/page/security/detection-form/command.ts new file mode 100644 index 0000000000..86678d42c4 --- /dev/null +++ b/apps/web/src/page/security/detection-form/command.ts @@ -0,0 +1,33 @@ +import { AlertRuleId, AuditDetectionCreateRequest } from "@maple/domain/http" +import { Schema } from "effect" +import { Command } from "foldkit" +import { settleTo } from "@maple/views/data/load-failure" +import { fetchSessionGrants } from "../../kit/session-grants" +import { createDetection, listDetections, updateDetection } from "../kit/detection-api" +import { Message } from "./model" + +// The detection editor's reads and its save. Names start with `SecurityApi` (and the session's +// with `ReadSecurity`) so the lab drops them. + +export const ReadDetectionFormGrants = fetchSessionGrants( + "ReadSecurityDetectionFormGrants", + Message.CompletedReadGrants, +) + +/** The org's detections: there is no read of one, and the list holds at most 50. */ +export const FetchExistingDetection = Command.define("SecurityApiDetectionFormExisting", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchExisting], + execute: ({ key }) => + listDetections.pipe(settleTo((result) => Message.CompletedFetchExisting({ key, result }))), +}) + +/** Creates the detection, or replaces the edited one's fields; the answer is what was stored. */ +export const SaveDetection = Command.define("SecurityApiDetectionSave", { + args: { editingId: Schema.NullOr(AlertRuleId), payload: AuditDetectionCreateRequest }, + messages: [Message.CompletedSave], + execute: ({ editingId, payload }) => + (editingId === null ? createDetection(payload) : updateDetection(editingId, payload)).pipe( + settleTo((result) => Message.CompletedSave({ result })), + ), +}) diff --git a/apps/web/src/page/security/detection-form/derive.ts b/apps/web/src/page/security/detection-form/derive.ts new file mode 100644 index 0000000000..733b033bbe --- /dev/null +++ b/apps/web/src/page/security/detection-form/derive.ts @@ -0,0 +1,150 @@ +import { + AuditDetectionFilters, + AuditTrailCategory, + AuditTrailOutcome, + AuditTrailProvider, +} from "@maple/domain/audit-trail" +import { + type AlertRuleId, + type AuditDetection, + AuditDetectionCreateRequest, + type AuditDetectionListResponse, +} from "@maple/domain/http" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import type { SecurityDetectionNewSearch } from "../../../route/search" +import { type DetectionTemplate, DETECTION_TEMPLATES, templateOf } from "../kit/detection-templates" +import { emptyForm, type Form } from "./form" + +const isProvider = Schema.is(AuditTrailProvider) +const isCategory = Schema.is(AuditTrailCategory) +const isOutcome = Schema.is(AuditTrailOutcome) + +// The request schema is the one source of the bounds: a field is valid when the API would take it. +const fields = AuditDetectionCreateRequest.fields +const isName = Schema.is(fields.name) +const isThreshold = Schema.is(fields.threshold) +const isWindow = Schema.is(fields.windowMinutes) +const isFilters = Schema.is(AuditDetectionFilters) + +const numberOf = (raw: string, isValid: (value: number) => boolean): Option.Option => { + const text = Str.trim(raw) + const value = /^\d+$/.test(text) ? Number(text) : Number.NaN + return isValid(value) ? Option.some(value) : Option.none() +} + +/** The count typed, when it is a whole number the API takes (1 to 100,000). */ +export const thresholdOf = (form: Form): Option.Option => numberOf(form.threshold, isThreshold) + +/** The window typed, in minutes, when the API takes it (5 minutes to 24 hours). */ +export const windowOf = (form: Form): Option.Option => numberOf(form.windowMinutes, isWindow) + +/** What the form counts. A select value this build has no name for counts as "Any". */ +export const filtersOf = (form: Form): AuditDetectionFilters => { + const action = Str.trim(form.action) + return { + ...(isProvider(form.provider) ? { provider: form.provider } : undefined), + ...(isCategory(form.category) ? { category: form.category } : undefined), + ...(isOutcome(form.outcome) ? { outcome: form.outcome } : undefined), + ...(action === "" ? undefined : { action }), + } +} + +/** What still blocks saving, one sentence each. The bounds are the request schema's. */ +export const formIssues = (form: Form): ReadonlyArray => { + const name = Str.trim(form.name) + return [ + ...(name === "" ? ["Name the detection."] : []), + ...(name !== "" && !isName(name) ? ["Keep the name to 120 characters."] : []), + // The selects only hold values the schema knows, so the action is what can be out of bounds. + ...(isFilters(filtersOf(form)) ? [] : ["Keep the action to 256 characters."]), + ...(Option.isNone(thresholdOf(form)) ? ["Set a count from 1 to 100,000."] : []), + ...(Option.isNone(windowOf(form)) ? ["Set a window from 5 to 1,440 minutes."] : []), + ...(Schema.is(fields.destinationIds)(form.destinationIds) ? [] : ["Notify at most 20 destinations."]), + ] +} + +/** The create body, also sent whole on an edit: `filters` replaces the stored ones. */ +export const wirePayload = (form: Form): AuditDetectionCreateRequest => ({ + name: Str.trim(form.name), + enabled: form.enabled, + filters: filtersOf(form), + windowMinutes: Option.getOrElse(windowOf(form), () => 0), + threshold: Option.getOrElse(thresholdOf(form), () => 0), + severity: form.severity, + destinationIds: form.destinationIds, +}) + +export const formFromDetection = (detection: AuditDetection): Form => ({ + name: detection.name, + provider: detection.filters.provider ?? "", + category: detection.filters.category ?? "", + outcome: detection.filters.outcome ?? "", + action: detection.filters.action ?? "", + threshold: String(detection.threshold), + windowMinutes: String(detection.windowMinutes), + severity: detection.severity, + destinationIds: detection.destinationIds, + enabled: detection.enabled, +}) + +const isTemplateTitle = (name: string): boolean => + Arr.some(DETECTION_TEMPLATES, (template) => template.title === name) + +/** + * `form` started over from `template`: what it counts, when it fires and how severe. A name the + * user typed stays; the destinations and the switch are theirs too. + */ +export const withTemplate = (form: Form, template: DetectionTemplate): Form => ({ + ...form, + name: Str.trim(form.name) === "" || isTemplateTitle(form.name) ? template.title : form.name, + provider: template.filters.provider ?? "", + category: template.filters.category ?? "", + outcome: template.filters.outcome ?? "", + action: template.filters.action ?? "", + threshold: String(template.threshold), + windowMinutes: String(template.windowMinutes), + severity: template.severity, +}) + +/** + * `form` with what a template set put back to a blank detection's. A name the user typed stays, + * like the destinations and the switch. + */ +export const withoutTemplate = (form: Form): Form => { + const blank = emptyForm() + return { + ...form, + name: isTemplateTitle(form.name) ? blank.name : form.name, + provider: blank.provider, + category: blank.category, + outcome: blank.outcome, + action: blank.action, + threshold: blank.threshold, + windowMinutes: blank.windowMinutes, + severity: blank.severity, + } +} + +/** + * The form a link prefills: a ready-made template, then the audit log's filters over it. A + * platform, category or outcome this build has no name for is dropped, not sent to the API. + */ +export const formFromSearch = (search: SecurityDetectionNewSearch): Form => { + const base = Option.match(templateOf(search.template), { + onNone: () => emptyForm(), + onSome: (template) => withTemplate(emptyForm(), template), + }) + return { + ...base, + ...(isProvider(search.provider) ? { provider: search.provider } : undefined), + ...(isCategory(search.category) ? { category: search.category } : undefined), + ...(isOutcome(search.outcome) ? { outcome: search.outcome } : undefined), + ...(search.action === undefined ? undefined : { action: Str.trim(search.action) }), + } +} + +/** The detection the editor is on, once the list that holds it answered. */ +export const detectionIn = ( + list: AuditDetectionListResponse, + id: AlertRuleId | null, +): Option.Option => Arr.findFirst(list.detections, (detection) => detection.id === id) diff --git a/apps/web/src/page/security/detection-form/detection-form.scene.test.ts b/apps/web/src/page/security/detection-form/detection-form.scene.test.ts new file mode 100644 index 0000000000..eaec4d56c7 --- /dev/null +++ b/apps/web/src/page/security/detection-form/detection-form.scene.test.ts @@ -0,0 +1,469 @@ +import { OrgId } from "@maple/domain" +import { + AlertRuleId, + AuditDetectionInvalidError, + AuditTrailForbiddenError, + type AuditDetection, + type AuditDetectionCreateRequest, + type AuditDetectionListResponse, +} from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { toLoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { Array as Arr, Result, Schema, String as Str } from "effect" +import { + change, + click, + Command, + expect as expectView, + expectOutMessage, + given, + role, + scene, + selector, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { + detectionDestinationsOf, + detectionsOf, + ON_CALL_ID, + SECURITY_CHANNEL_ID, +} from "../../../lab/security-detections-fixture" +import { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage } from "../../contract" +import { SaveDetection } from "./command" +import { formIssues, wirePayload } from "./derive" +import { emptyForm } from "./form" +import { Message, type Model } from "./model" +import { enter, update } from "./update" +import { view as pageView } from "./view" + +const context: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_acme"), + pinnedNamespace: null, + timeZone: "UTC", +} +const NOW = Date.UTC(2026, 9, 31, 14, 32) +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const EXISTING = "SecurityApiDetectionFormExisting" +const SAVE = "SecurityApiDetectionSave" + +const ADMIN = Policy.allPermissions +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const adminsOnly = text("Only org admins can create or change detections") +const createButton = role("button", { name: "Create detection" }) +const saveButton = role("button", { name: "Save changes" }) +const issues = selector('[aria-label="Fix before saving"]') + +const routeOf = (search: Model["search"] = {}) => AppRoute.SecurityDetectionNew({ search }) +/** The hosted product: the session, not the build, says what the viewer may do. */ +const init = (search: Model["search"] = {}) => enter(routeOf(search), context, true) + +const steps = (model: Model, messages: ReadonlyArray): Model => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const grantsRead = (grants: ReadonlyArray) => + Message.CompletedReadGrants({ orgId: context.orgId, grants: [...grants] }) + +const destinationsSynced = Message.UpdatedDestinations({ + orgId: context.orgId, + rows: [...detectionDestinationsOf(context.orgId, NOW)], +}) + +/** The editor as an admin opens it on a new detection. */ +const fresh = (search: Model["search"] = {}): Model => + steps(init(search).model, [grantsRead(ADMIN), destinationsSynced]) + +const LIST: AuditDetectionListResponse = detectionsOf(NOW) +const [VERCEL_ROLES] = LIST.detections + +/** The editor on `detection`, once the list that holds it answered. */ +const editing = (detection: AuditDetection | undefined, list = LIST): Model => { + const entered = init({ detectionId: detection?.id ?? "" }) + const asked = steps(entered.model, [grantsRead(ADMIN), destinationsSynced]) + return pageUpdate( + asked, + Message.CompletedFetchExisting({ key: asked.existing.key ?? "", result: Result.succeed(list) }), + ).model +} + +/** The detection as the API answers a save of `payload`. */ +const saved = (payload: AuditDetectionCreateRequest): AuditDetection => ({ + id: VERCEL_ROLES?.id ?? Schema.decodeSync(AlertRuleId)("0199b002-0000-7000-8000-000000000009"), + name: payload.name, + enabled: payload.enabled ?? true, + filters: payload.filters ?? {}, + windowMinutes: payload.windowMinutes, + threshold: payload.threshold, + severity: payload.severity, + destinationIds: payload.destinationIds ?? [], + createdAt: NOW, + updatedAt: NOW, +}) + +describe("security detection editor", () => { + test("nothing is asked of the API until the session says who is looking", () => { + expect(names(init({ detectionId: VERCEL_ROLES?.id }).commands)).not.toContain(EXISTING) + scene( + { update: pageUpdate, view }, + given(init().model), + expectView(createButton).toBeAbsent(), + expectView(adminsOnly).toBeAbsent(), + ) + }) + + test("an engineer is told the editor is for admins and gets no form", () => { + const answered = pageUpdate(init({ detectionId: VERCEL_ROLES?.id }).model, grantsRead(ENGINEER)) + + expect(names(answered.commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(answered.model), + expectView(adminsOnly).toExist(), + expectView(saveButton).toBeAbsent(), + expectView(role("link", { name: "Back to detections" })).toHaveAttr( + "href", + "/security/detections", + ), + ) + }) + + // A failed session read answers with no grants at all: the form opens, and the API decides. + test("a session that could not be read opens the form, and the API's refusal is what locks it", () => { + const open = steps(init().model, [ + grantsRead([]), + Message.ChangedText({ field: "name", value: "Any" }), + ]) + scene({ update: pageUpdate, view }, given(open), expectView(createButton).toExist()) + + const refused = steps(open, [ + Message.ClickedSave(), + Message.CompletedSave({ + result: Result.fail( + toLoadFailure( + new AuditTrailForbiddenError({ message: "Only org admins can read the audit log." }), + ), + ), + }), + ]) + scene( + { update: pageUpdate, view }, + given(refused), + expectView(adminsOnly).toExist(), + expectView(createButton).toBeAbsent(), + ) + }) + + test("a new detection counts every event, says what is missing and cannot be created", () => { + scene( + { update: pageUpdate, view }, + given(fresh()), + expectView(role("heading", { name: "New detection" })).toExist(), + expectView(role("combobox", { name: "Platform" })).toHaveValue(""), + expectView(text("Every audit event")).toExist(), + expectView(issues).toHaveText("Fix before saving: Name the detection."), + expectView(createButton).toBeDisabled(), + ) + }) + + test("a ready-made detection opens filled in, and nothing is created by opening it", () => { + const entered = init({ template: "sign_in" }) + + expect(names(entered.commands)).not.toContain(SAVE) + scene( + { update: pageUpdate, view }, + given(steps(entered.model, [grantsRead(ADMIN), destinationsSynced])), + expectView(role("textbox", { name: "Name" })).toHaveValue("Repeated sign-in failures"), + expectView(role("combobox", { name: "Start from" })).toHaveValue("sign_in"), + expectView(role("combobox", { name: "Category" })).toHaveValue("authentication"), + expectView(role("combobox", { name: "Outcome" })).toHaveValue("failure"), + expectView(role("textbox", { name: "Count" })).toHaveValue("5"), + expectView(role("textbox", { name: "Window in minutes" })).toHaveValue("10"), + expectView(role("button", { name: "Critical" })).toHaveAttr("aria-pressed", "true"), + expectView(text("Fires on 5 or more in 10 minutes. Checked every minute.")).toExist(), + expectView(text("Ready to save")).toExist(), + ) + }) + + test("the audit log's filters prefill what it counts, and nothing else", () => { + const prefilled = fresh({ + provider: "vercel", + category: "credential", + outcome: "denied", + action: " project.env-var.created ", + }) + + expect(wirePayload(prefilled.form).filters).toEqual({ + provider: "vercel", + category: "credential", + outcome: "denied", + action: "project.env-var.created", + }) + scene( + { update: pageUpdate, view }, + given(prefilled), + expectView(role("combobox", { name: "Platform" })).toHaveValue("vercel"), + expectView(role("textbox", { name: "Action (optional)" })).toHaveValue("project.env-var.created"), + expectView(role("textbox", { name: "Name" })).toHaveValue(""), + ) + }) + + // The URL is anyone's to write: a platform, category or outcome the API has no name for + // counts as "Any" and is never sent. + test("a prefill value this build does not know is dropped", () => { + const odd = fresh({ + provider: "github", + category: "licensing", + outcome: "throttled", + template: "nope", + }) + + expect(wirePayload(odd.form).filters).toEqual({}) + scene( + { update: pageUpdate, view }, + given(odd), + expectView(role("combobox", { name: "Platform" })).toHaveValue(""), + expectView(role("combobox", { name: "Start from" })).toHaveValue(""), + ) + }) + + test("the count and the window are held to the bounds the API takes", () => { + const named = { ...emptyForm(), name: "Refusals" } + const issuesOf = (fields: Partial) => formIssues({ ...named, ...fields }) + + expect(issuesOf({})).toEqual([]) + expect(issuesOf({ threshold: "0" })).toEqual(["Set a count from 1 to 100,000."]) + expect(issuesOf({ threshold: "100001" })).toEqual(["Set a count from 1 to 100,000."]) + expect(issuesOf({ threshold: "2.5" })).toEqual(["Set a count from 1 to 100,000."]) + expect(issuesOf({ threshold: "100000", windowMinutes: "1440" })).toEqual([]) + expect(issuesOf({ windowMinutes: "4" })).toEqual(["Set a window from 5 to 1,440 minutes."]) + expect(issuesOf({ windowMinutes: "1441" })).toEqual(["Set a window from 5 to 1,440 minutes."]) + expect(issuesOf({ windowMinutes: "" })).toEqual(["Set a window from 5 to 1,440 minutes."]) + }) + + test("a name or an action longer than the API takes blocks saving", () => { + const named = { ...emptyForm(), name: "Refusals" } + + expect(formIssues({ ...named, name: Str.repeat(120)("a") })).toEqual([]) + expect(formIssues({ ...named, name: Str.repeat(121)("a") })).toEqual([ + "Keep the name to 120 characters.", + ]) + expect(formIssues({ ...named, name: " " })).toEqual(["Name the detection."]) + expect(formIssues({ ...named, action: Str.repeat(256)("a") })).toEqual([]) + expect(formIssues({ ...named, action: Str.repeat(257)("a") })).toEqual([ + "Keep the action to 256 characters.", + ]) + }) + + test("a window out of bounds is named in the bar and the button stays off until it is fixed", () => { + scene( + { update: pageUpdate, view }, + given(fresh({ template: "refused" })), + type(role("textbox", { name: "Window in minutes" }), "2"), + expectView(issues).toHaveText("Fix before saving: Set a window from 5 to 1,440 minutes."), + expectView(createButton).toBeDisabled(), + type(role("textbox", { name: "Window in minutes" }), "30"), + expectView(text("Ready to save")).toExist(), + expectView(createButton).not.toBeDisabled(), + ) + }) + + test("creating sends the form as the API takes it and goes back to the list on its answer", () => { + const payload: AuditDetectionCreateRequest = { + name: "Denied credential changes", + enabled: true, + filters: { category: "credential", outcome: "denied" }, + windowMinutes: 15, + threshold: 3, + severity: "critical", + destinationIds: [], + } + + scene( + { update: pageUpdate, view }, + given(fresh()), + type(role("textbox", { name: "Name" }), " Denied credential changes "), + change(role("combobox", { name: "Category" }), "credential"), + change(role("combobox", { name: "Outcome" }), "denied"), + type(role("textbox", { name: "Count" }), "3"), + type(role("textbox", { name: "Window in minutes" }), "15"), + click(role("button", { name: "Critical" })), + expectView(text("Credentials · Denied")).toExist(), + click(createButton), + Command.resolve( + SaveDetection({ editingId: null, payload }), + Message.CompletedSave({ result: Result.succeed(saved(payload)) }), + ), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: "/security/detections", + isReplace: true, + toast: { type: "success", title: "Detection created", description: "" }, + }), + ), + ) + }) + + test("editing reads the detection, fills the form from it and saves the change to that detection", () => { + const detection = VERCEL_ROLES + const entered = init({ detectionId: detection?.id }) + expect(names(pageUpdate(entered.model, grantsRead(ADMIN)).commands)).toEqual([EXISTING]) + + const payload: AuditDetectionCreateRequest = { + name: "Role changes on Vercel", + enabled: true, + filters: { provider: "vercel", action: "team.member.role.updated" }, + windowMinutes: 5, + threshold: 2, + severity: "warning", + destinationIds: [SECURITY_CHANNEL_ID], + } + scene( + { update: pageUpdate, view }, + given(editing(detection)), + expectView(role("heading", { name: "Edit detection" })).toExist(), + expectView(role("combobox", { name: "Start from" })).toBeAbsent(), + expectView(role("textbox", { name: "Name" })).toHaveValue("Role changes on Vercel"), + expectView(role("combobox", { name: "Platform" })).toHaveValue("vercel"), + expectView(role("textbox", { name: "Action (optional)" })).toHaveValue( + "team.member.role.updated", + ), + expectView(text("#security")).toExist(), + type(role("textbox", { name: "Count" }), "2"), + click(saveButton), + Command.resolve( + SaveDetection({ editingId: detection?.id ?? null, payload }), + Message.CompletedSave({ result: Result.succeed(saved(payload)) }), + ), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: "/security/detections", + isReplace: true, + toast: { type: "success", title: "Changes saved", description: "" }, + }), + ), + ) + }) + + // A duplicate name, an unknown destination and the per-org cap all come back as this error. + test("a save the API calls invalid stays on the form and shows the API's own message", () => { + const message = 'Another alert rule is already named "Burst of refused requests".' + const failed = steps(fresh({ template: "refused" }), [ + Message.ClickedSave(), + Message.CompletedSave({ + result: Result.fail(toLoadFailure(new AuditDetectionInvalidError({ message }))), + }), + ]) + + scene( + { update: pageUpdate, view }, + given(failed), + expectView(text("Couldn't save the detection")).toExist(), + expectView(text(message)).toExist(), + expectView(createButton).not.toBeDisabled(), + ) + }) + + // The API's message is about the form as it was sent: it must not sit over a corrected one. + test("changing the form takes the API's refusal off the screen", () => { + const message = 'Another alert rule is already named "Burst of refused requests".' + const corrected = steps(fresh({ template: "refused" }), [ + Message.ClickedSave(), + Message.CompletedSave({ + result: Result.fail(toLoadFailure(new AuditDetectionInvalidError({ message }))), + }), + Message.ChangedText({ field: "name", value: "Refused requests, EU" }), + ]) + + scene({ update: pageUpdate, view }, given(corrected), expectView(text(message)).not.toExist()) + }) + + test("a save in flight is not sent twice", () => { + const saving = pageUpdate(fresh({ template: "refused" }), Message.ClickedSave()) + + expect(names(saving.commands)).toEqual([SAVE]) + expect(names(pageUpdate(saving.model, Message.ClickedSave()).commands)).toEqual([]) + }) + + test("the destinations it notifies are picked from the org's and can be removed", () => { + const picked = pageUpdate( + fresh({ template: "refused" }), + Message.GotDestinationMenuMessage({ + message: Menu.Message.ClickedItem({ index: 1, item: { kind: "Item", value: ON_CALL_ID } }), + }), + ).model + + expect(wirePayload(picked.form).destinationIds).toEqual([ON_CALL_ID]) + scene( + { update: pageUpdate, view }, + given(picked), + click(role("button", { name: "Stop notifying Security on-call" })), + expectView(role("button", { name: "Stop notifying Security on-call" })).toBeAbsent(), + expectView(text("Nobody yet. The incident still opens, and no one is told.")).toExist(), + ) + }) + + test("starting from a ready-made detection in the editor keeps a name already typed", () => { + const typed = pageUpdate(fresh(), Message.ChangedText({ field: "name", value: "Key churn" })).model + const picked = pageUpdate(typed, Message.PickedTemplate({ id: "credential" })).model + + expect(wirePayload(picked.form)).toMatchObject({ + name: "Key churn", + filters: { category: "credential" }, + threshold: 1, + windowMinutes: 5, + severity: "warning", + }) + // A name that came from a template follows the next template. + const switched = steps(fresh({ template: "membership" }), [Message.PickedTemplate({ id: "refused" })]) + expect(switched.form.name).toBe("Burst of refused requests") + }) + + test("going back to a blank detection undoes what the template set and keeps what was typed", () => { + const blank = wirePayload(fresh().form) + const fromTemplate = steps(fresh(), [ + Message.PickedTemplate({ id: "refused" }), + Message.PickedTemplate({ id: "" }), + ]) + expect(wirePayload(fromTemplate.form)).toEqual(blank) + + const named = steps(fresh(), [ + Message.ChangedText({ field: "name", value: "Key churn" }), + Message.PickedTemplate({ id: "credential" }), + Message.PickedTemplate({ id: "" }), + ]) + expect(wirePayload(named.form)).toEqual({ ...blank, name: "Key churn" }) + + // With no template picked there is nothing to undo: filters set by hand stay. + const byHand = steps(fresh(), [ + Message.ChangedSelect({ field: "outcome", value: "denied" }), + Message.PickedTemplate({ id: "" }), + ]) + expect(wirePayload(byHand.form)).toMatchObject({ filters: { outcome: "denied" } }) + }) + + test("a detection the org no longer has, or an id that is not one, says so", () => { + const gone = editing(VERCEL_ROLES, { detections: [] }) + scene( + { update: pageUpdate, view }, + given(gone), + expectView(text("Detection not found")).toExist(), + expectView(saveButton).toBeAbsent(), + ) + + const mistyped = init({ detectionId: "not-an-id" }) + expect(names(pageUpdate(mistyped.model, grantsRead(ADMIN)).commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(pageUpdate(mistyped.model, grantsRead(ADMIN)).model), + expectView(text("Detection not found")).toExist(), + expectView(role("heading", { name: "New detection" })).toBeAbsent(), + ) + }) +}) diff --git a/apps/web/src/page/security/detection-form/form.ts b/apps/web/src/page/security/detection-form/form.ts new file mode 100644 index 0000000000..24ac4ec989 --- /dev/null +++ b/apps/web/src/page/security/detection-form/form.ts @@ -0,0 +1,42 @@ +import { AlertDestinationId, AlertSeverity } from "@maple/domain/http" +import { Schema } from "effect" + +/** The typed inputs, addressed by name from one Message. */ +export const TextField = Schema.Literals(["name", "action", "threshold", "windowMinutes"]) +export type TextField = typeof TextField.Type + +/** The filters a select sets; its empty value is "Any". */ +export const SelectField = Schema.Literals(["provider", "category", "outcome"]) +export type SelectField = typeof SelectField.Type + +export const Form = Schema.Struct({ + name: Schema.String, + /** `""` counts every platform; likewise the category and the outcome. */ + provider: Schema.String, + category: Schema.String, + outcome: Schema.String, + /** The platform's own action name, matched exactly; empty counts every action. */ + action: Schema.String, + /** As typed: the count and the window are numbers only once they are valid. */ + threshold: Schema.String, + windowMinutes: Schema.String, + severity: AlertSeverity, + destinationIds: Schema.Array(AlertDestinationId), + enabled: Schema.Boolean, +}) +export type Form = typeof Form.Type + +/** A new detection: every event, one or more in 5 minutes, a warning, on, nobody notified yet. */ +export const emptyForm = (fields: Partial
= {}): Form => ({ + name: "", + provider: "", + category: "", + outcome: "", + action: "", + threshold: "1", + windowMinutes: "5", + severity: "warning", + destinationIds: [], + enabled: true, + ...fields, +}) diff --git a/apps/web/src/page/security/detection-form/index.ts b/apps/web/src/page/security/detection-form/index.ts new file mode 100644 index 0000000000..36c4de4432 --- /dev/null +++ b/apps/web/src/page/security/detection-form/index.ts @@ -0,0 +1,36 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { definePage, type PageSubscriptionInput } from "../../contract" +import { destinationRows } from "../../kit/alert-collections" +import { detectionFormCrumbs } from "../kit/layout" +import { Message, Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +// The org's alert destinations, live: the same rows the SLO and uptime editors pick from. +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + destinations: entry( + { orgId: Schema.String }, + { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }, + ), +})) + +/** `/security/detections/new`: one instance per detection being edited, or the new one. */ +export const page = definePage( + "SecurityDetectionNew", + { Model, Message }, + { + routes: ["SecurityDetectionNew"], + key: (route) => route.search.detectionId ?? "new", + init, + update, + view, + subscriptions, + breadcrumbs: () => detectionFormCrumbs(), + }, +) diff --git a/apps/web/src/page/security/detection-form/model.ts b/apps/web/src/page/security/detection-form/model.ts new file mode 100644 index 0000000000..0c9af7580f --- /dev/null +++ b/apps/web/src/page/security/detection-form/model.ts @@ -0,0 +1,62 @@ +import { + AlertDestinationId, + AlertRuleId, + AlertSeverity, + AuditDetection, + AuditDetectionListResponse, +} from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { AlertDestinationRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { SecurityDetectionNewSearch } from "../../../route/search" +import { LiveRows, LoadOutcome } from "../../kit/load-schemas" +import { SessionGrants } from "../../kit/session-grants" +import { Form, SelectField, TextField } from "./form" + +// `/security/detections/new`: the editor of one audit log detection, new or (`?detectionId=`) +// existing. Only org admins (`audit_log:read`) open it. + +export const Model = Schema.Struct({ + search: SecurityDetectionNewSearch, + /** The detection being edited; `null` creates. */ + editingId: Schema.NullOr(AlertRuleId), + /** The URL names a detection that is not an id, or that the org does not have. */ + isMissing: Schema.Boolean, + /** The org's detections, read to find the edited one; the form fills from it once. */ + existing: KeyedSchema(AuditDetectionListResponse, LoadFailure), + form: Form, + /** The ready-made detection last picked in "Start from"; `""` is none. */ + templateId: Schema.String, + isSaving: Schema.Boolean, + /** The last save's failure, shown above the action bar in the server's words. */ + saveFailure: Schema.NullOr(LoadFailure), + destinations: LiveRows(AlertDestinationRowSchema), + destinationMenu: Menu.Model, + grants: Schema.Array(Policy.PermissionName), + /** False until the session answers: the page says nothing about access before it knows. */ + isGrantsRead: Schema.Boolean, +}) +export type Model = typeof Model.Type + +export { auditAccessOf as accessOf } from "../kit/audit-access" + +export const Message = defineMessageUnion({ + CompletedReadGrants: SessionGrants, + CompletedFetchExisting: { key: Schema.String, result: LoadOutcome(AuditDetectionListResponse) }, + ClickedRetry: {}, + UpdatedDestinations: { orgId: Schema.String, rows: Schema.Array(AlertDestinationRowSchema) }, + PickedTemplate: { id: Schema.String }, + ChangedText: { field: TextField, value: Schema.String }, + ChangedSelect: { field: SelectField, value: Schema.String }, + PickedSeverity: { severity: AlertSeverity }, + ToggledEnabled: {}, + RemovedDestination: { id: AlertDestinationId }, + GotDestinationMenuMessage: { message: Menu.Message }, + ClickedSave: {}, + CompletedSave: { result: LoadOutcome(AuditDetection) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/security/detection-form/update.ts b/apps/web/src/page/security/detection-form/update.ts new file mode 100644 index 0000000000..f7c12f67b8 --- /dev/null +++ b/apps/web/src/page/security/detection-form/update.ts @@ -0,0 +1,191 @@ +import { AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Menu from "@maple/views/ui/menu" +import { Array as Arr, Option, Result, Schema } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import { isClerkAuthEnabled } from "../../../platform/auth-mode" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { pickedDestination } from "../../kit/destination-picker" +import { chain, slotStep } from "../../kit/keyed-step" +import { acceptGrants } from "../../kit/session-grants" +import { detectionsHref } from "../kit/detection-format" +import { templateOf } from "../kit/detection-templates" +import { FetchExistingDetection, ReadDetectionFormGrants, SaveDetection } from "./command" +import { + detectionIn, + formFromDetection, + formFromSearch, + formIssues, + wirePayload, + withoutTemplate, + withTemplate, +} from "./derive" +import type { Form } from "./form" +import { accessOf, Message, type Model } from "./model" + +type Return = PageReturn + +type SecurityDetectionNewRoute = Extract + +const DESTINATION_MENU_ID = "security-detection-destinations" + +const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) + +/** The edited detection's read, unless the session said the viewer may not read it. */ +const readExisting = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + model.editingId === null || accessOf(model) !== "ask" + ? { model } + : slotStep( + model, + model.existing, + (existing) => ({ existing }), + requestKey(context.orgId, "auditTrail.listDetections", {}), + mode, + (key) => FetchExistingDetection({ key }), + ) + +/** + * Entering the editor. `sessionDecides`: the hosted product, where the session says what the + * viewer may do and nothing is asked before it answers. Self-hosted has one role and reads at once. + */ +export const enter = ( + route: SecurityDetectionNewRoute, + context: PageContext, + sessionDecides: boolean, +): Return => { + const named = route.search.detectionId + const editingId = named === undefined ? null : Option.getOrNull(decodeRuleId(named)) + const model: Model = { + search: route.search, + editingId, + isMissing: named !== undefined && editingId === null, + existing: Keyed.idle(), + form: formFromSearch(route.search), + templateId: Option.match(templateOf(route.search.template), { + onNone: () => "", + onSome: (template) => template.id, + }), + isSaving: false, + saveFailure: null, + destinations: null, + destinationMenu: Menu.init(DESTINATION_MENU_ID), + grants: sessionDecides ? [] : Policy.allPermissions, + isGrantsRead: !sessionDecides, + } + const entered: Return = { + model, + commands: sessionDecides ? [ReadDetectionFormGrants({ orgId: context.orgId })] : [], + } + return chain(entered, (next) => readExisting(next, context, "Load")) +} + +export const init = (route: SecurityDetectionNewRoute, context: PageContext): Return => + enter(route, context, isClerkAuthEnabled) + +// A change answers the server's last refusal: its message is about the form as it was. +const withForm = (model: Model, patch: Partial): Return => ({ + model: { ...model, form: { ...model.form, ...patch }, saveFailure: null }, +}) + +/** The "Add destination" menu: a pick of a synced destination adds it. */ +const updateDestinationMenu = (model: Model, message: Menu.Message): Return => { + const result = Menu.update(model.destinationMenu, message) + const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => + Message.GotDestinationMenuMessage({ message: child }), + ) + const next: Model = { ...model, destinationMenu: result.model } + const ids = model.form.destinationIds + return Option.match( + Option.flatMap(pickedDestination(result.outMessage, model.destinations, ids), decodeDestinationId), + { + onNone: () => ({ model: next, commands }), + onSome: (id) => ({ + model: { ...next, form: { ...next.form, destinationIds: [...ids, id] } }, + commands, + }), + }, + ) +} + +const save = (model: Model): Return => + model.isSaving || model.isMissing || formIssues(model.form).length > 0 + ? { model } + : { + model: { ...model, isSaving: true, saveFailure: null }, + commands: [SaveDetection({ editingId: model.editingId, payload: wirePayload(model.form) })], + } + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + CompletedReadGrants: (answer) => + readExisting(acceptGrants(model, context.orgId, answer, { isGrantsRead: true }), context, "Load"), + CompletedFetchExisting: ({ key, result }) => { + const existing = Keyed.accept(model.existing, key, result) + if (existing === model.existing) return { model } + return Option.match(AsyncData.getData(existing.data), { + onNone: (): Return => ({ model: { ...model, existing } }), + onSome: (list): Return => + Option.match(detectionIn(list, model.editingId), { + onNone: (): Return => ({ model: { ...model, existing, isMissing: true } }), + onSome: (detection): Return => ({ + model: { ...model, existing, form: formFromDetection(detection) }, + }), + }), + }) + }, + ClickedRetry: () => readExisting(model, context, "Refresh"), + UpdatedDestinations: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, + PickedTemplate: ({ id }) => + Option.match(templateOf(id), { + // Back to a blank detection only from a template: nothing typed by hand is wiped. + onNone: (): Return => ({ + model: { + ...model, + templateId: "", + form: model.templateId === "" ? model.form : withoutTemplate(model.form), + saveFailure: null, + }, + }), + onSome: (template): Return => ({ + model: { + ...model, + templateId: template.id, + form: withTemplate(model.form, template), + saveFailure: null, + }, + }), + }), + ChangedText: ({ field, value }) => withForm(model, { [field]: value }), + ChangedSelect: ({ field, value }) => withForm(model, { [field]: value }), + PickedSeverity: ({ severity }) => withForm(model, { severity }), + ToggledEnabled: () => withForm(model, { enabled: !model.form.enabled }), + RemovedDestination: ({ id }) => + withForm(model, { + destinationIds: Arr.filter(model.form.destinationIds, (existing) => existing !== id), + }), + GotDestinationMenuMessage: ({ message: child }) => updateDestinationMenu(model, child), + ClickedSave: () => save(model), + CompletedSave: ({ result }) => + Result.match(result, { + // The form takes the API's answer, and the list is read again on arrival. + onSuccess: (saved): Return => ({ + model: { ...model, isSaving: false, form: formFromDetection(saved) }, + outMessage: PageOutMessage.RequestedNavigation({ + url: detectionsHref(), + isReplace: true, + toast: { + type: "success", + title: model.editingId === null ? "Detection created" : "Changes saved", + description: "", + }, + }), + }), + // Inline above the action bar, in the server's words: a taken name, the cap. + onFailure: (saveFailure): Return => ({ model: { ...model, isSaving: false, saveFailure } }), + }), + }) diff --git a/apps/web/src/page/security/detection-form/view.ts b/apps/web/src/page/security/detection-form/view.ts new file mode 100644 index 0000000000..67704690d0 --- /dev/null +++ b/apps/web/src/page/security/detection-form/view.ts @@ -0,0 +1,347 @@ +import { AuditTrailCategory, AuditTrailProvider } from "@maple/domain/audit-trail" +import { Array as Arr, Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "@maple/views/data/load-failure" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { inlineCode } from "@maple/views/ui/inline-code" +import { input } from "@maple/views/ui/input" +import { inputGroup, inputGroupAddon, inputGroupInput, inputGroupText } from "@maple/views/ui/input-group" +import { label } from "@maple/views/ui/label" +import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { switchControl } from "@maple/views/ui/switch" +import { typography, typographyClass } from "@maple/views/ui/typography" +import { ALERT_SEVERITY_LABEL } from "../../alerts/shared/badges" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { destinationPicker } from "../../kit/destination-picker" +import { + formActionBar, + formCard, + formScrollArea, + formSection, + managersOnlyPage, +} from "../../kit/form-section" +import { resourceNotFound } from "../../kit/resource-not-found" +import { CATEGORY_LABEL, labelIn, OUTCOME_FILTERS, OUTCOME_LABEL, PROVIDER_LABEL } from "../kit/audit-labels" +import { conditionText, detectionsHref, EVERY_EVENT, isForbidden, matchText } from "../kit/detection-format" +import { DETECTION_TEMPLATES } from "../kit/detection-templates" +import { filtersOf, formIssues, thresholdOf, windowOf } from "./derive" +import type { Form, SelectField, TextField } from "./form" +import { accessOf, Message, type Model } from "./model" + +type H = HtmlBuilder + +const FORM_WIDTH = "max-w-3xl" + +const fieldId = (field: TextField | SelectField | "template") => `detection-${field}` + +const smallLabel = (h: H, field: TextField | SelectField | "template", text: string): Html => + label( + h, + { className: typographyClass({ size: "xs", tone: "muted" }), attributes: [h.For(fieldId(field))] }, + [text], + ) + +/** The ready-made detections, in the editor too: a pick fills the form and saves nothing. */ +const templateSection = (h: H, model: Model): Html => + formSection(h, { title: "Start from", hint: "A ready-made one", isCompact: true }, [ + label(h, { className: "sr-only", attributes: [h.For(fieldId("template"))] }, ["Start from"]), + nativeSelect( + h, + { + value: model.templateId, + className: "w-full max-w-sm", + attributes: [h.Id(fieldId("template")), h.OnChange((id) => Message.PickedTemplate({ id }))], + }, + [ + nativeOption("", "A blank detection"), + ...Arr.map(DETECTION_TEMPLATES, (template) => nativeOption(template.id, template.title)), + ], + ), + ]) + +const nameSection = (h: H, form: Form): Html => + formSection(h, { title: "Name", hint: "Members see it" }, [ + label(h, { className: "sr-only", attributes: [h.For(fieldId("name"))] }, ["Name"]), + input(h, { + id: fieldId("name"), + attributes: [ + h.Value(form.name), + h.Placeholder("Role changes on Vercel"), + h.OnInput((value) => Message.ChangedText({ field: "name", value })), + ], + }), + ]) + +/** One filter as a select whose first option counts everything. A stored value off the list stays listed. */ +const filterSelect = ( + h: H, + field: SelectField, + name: string, + value: string, + labels: Readonly>, + values: ReadonlyArray, +): Html => { + const listed = value === "" || values.includes(value) ? values : [...values, value] + return vstack(h, { gap: "1.5", className: "min-w-36 flex-1" }, [ + smallLabel(h, field, name), + nativeSelect( + h, + { + value, + className: "w-full", + attributes: [ + h.Id(fieldId(field)), + h.OnChange((next) => Message.ChangedSelect({ field, value: next })), + ], + }, + [ + nativeOption("", "Any"), + ...Arr.map(listed, (option) => nativeOption(option, labelIn(labels, option))), + ], + ), + ]) +} + +const matchesSection = (h: H, form: Form): Html => { + const filters = filtersOf(form) + const labels = matchText(filters) + return formSection(h, { title: "Matches", hint: "Events it counts" }, [ + hstack(h, { gap: "3", wrap: true, align: "start" }, [ + filterSelect( + h, + "provider", + "Platform", + form.provider, + PROVIDER_LABEL, + AuditTrailProvider.literals, + ), + filterSelect( + h, + "category", + "Category", + form.category, + CATEGORY_LABEL, + AuditTrailCategory.literals, + ), + filterSelect(h, "outcome", "Outcome", form.outcome, OUTCOME_LABEL, OUTCOME_FILTERS), + ]), + vstack(h, { gap: "1.5" }, [ + smallLabel(h, "action", "Action (optional)"), + input(h, { + id: fieldId("action"), + className: "font-mono", + attributes: [ + h.Value(form.action), + h.Placeholder("team.member.role.updated"), + h.Attribute("autocapitalize", "off"), + h.Attribute("spellcheck", "false"), + h.OnInput((value) => Message.ChangedText({ field: "action", value })), + ], + }), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "The platform's own action name, matched exactly, for example ", + inlineCode(h, { variant: "plain" }, ["team.member.role.updated"]), + ". Empty counts every action.", + ]), + ]), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "Counts: ", + typography(h, { tone: "foreground" }, [ + labels === "" && filters.action === undefined ? EVERY_EVENT : labels, + ]), + ...(filters.action === undefined + ? [] + : [ + labels === "" ? "" : " · ", + inlineCode(h, { variant: "plain", tone: "foreground" }, [filters.action]), + ]), + ]), + ]) +} + +/** A whole number with its unit after it, so the pair reads as a clause. */ +const numberField = ( + h: H, + field: "threshold" | "windowMinutes", + name: string, + value: string, + unit: string, +): Html => + vstack(h, { gap: "1.5", className: "w-44" }, [ + label(h, { className: "sr-only", attributes: [h.For(fieldId(field))] }, [name]), + inputGroup(h, {}, [ + inputGroupInput(h, { + id: fieldId(field), + attributes: [ + h.Value(value), + h.Attribute("inputmode", "numeric"), + h.OnInput((next) => Message.ChangedText({ field, value: next })), + ], + }), + inputGroupAddon(h, { align: "inline-end" }, [inputGroupText(h, {}, [unit])]), + ]), + ]) + +const conditionHint = (form: Form): string => + Option.match(Option.all([thresholdOf(form), windowOf(form)]), { + onNone: () => "A count from 1 to 100,000, over a window from 5 minutes to 24 hours.", + onSome: ([threshold, windowMinutes]) => + `Fires on ${conditionText(threshold, windowMinutes)}. Checked every minute.`, + }) + +const conditionSection = (h: H, form: Form): Html => + formSection(h, { title: "Fires on", hint: "Count and window" }, [ + hstack(h, { gap: "2", align: "center", wrap: true }, [ + numberField(h, "threshold", "Count", form.threshold, "or more"), + typography(h, { size: "sm", tone: "muted" }, ["in"]), + numberField(h, "windowMinutes", "Window in minutes", form.windowMinutes, "minutes"), + ]), + typography(h, { size: "xs", tone: "muted", as: "p" }, [conditionHint(form)]), + ]) + +const severitySection = (h: H, form: Form): Html => + formSection(h, { title: "Severity", hint: "Of its incidents", isCompact: true }, [ + segmentedSelect(h, { + ariaLabel: "Severity", + size: "sm", + className: "w-fit", + value: form.severity, + onChange: (severity) => Message.PickedSeverity({ severity }), + options: [ + { value: "warning", label: ALERT_SEVERITY_LABEL.warning }, + { value: "critical", label: ALERT_SEVERITY_LABEL.critical }, + ], + }), + ]) + +const notifySection = (h: H, model: Model): Html => + formSection(h, { title: "Notify", hint: "When it fires" }, [ + destinationPicker(h, { + rows: model.destinations, + selectedIds: model.form.destinationIds, + menu: model.destinationMenu, + toMenuMessage: (message) => Message.GotDestinationMenuMessage({ message }), + onRemove: (id) => Message.RemovedDestination({ id }), + emptyText: "Nobody yet. The incident still opens, and no one is told.", + isDisabled: model.isSaving, + }), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "A notification carries the count, never an event or who caused it.", + ]), + ]) + +const enabledSection = (h: H, form: Form): Html => + formSection(h, { title: "Enabled", isCompact: true }, [ + hstack(h, { gap: "2" }, [ + ...switchControl(h, { + id: "detection-enabled", + inputId: "detection-enabled-input", + isChecked: form.enabled, + labelledBy: "detection-enabled-label", + onToggle: Message.ToggledEnabled(), + }), + typography(h, { size: "sm", attributes: [h.Id("detection-enabled-label")] }, [ + "Count events and open incidents", + ]), + ]), + ]) + +const actionBar = (h: H, model: Model): Html => { + const issues = formIssues(model.form) + return formActionBar(h, { + issues, + widthClass: FORM_WIDTH, + notice: + model.saveFailure === null + ? null + : alert(h, { variant: "crit", size: "sm", className: `mb-3 ${FORM_WIDTH}` }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't save the detection"]), + alertDescription(h, [failureMessage(model.saveFailure)]), + ]), + actions: [ + linkButton(h, { variant: "outline", href: detectionsHref() }, ["Cancel"]), + button( + h, + { + isDisabled: issues.length > 0, + isLoading: model.isSaving, + attributes: [h.OnClick(Message.ClickedSave())], + }, + [model.editingId === null ? "Create detection" : "Save changes"], + ), + ], + }) +} + +const BACK = { href: detectionsHref(), label: "Back to detections" } + +/** The session, or the API when the session could not say, refused the viewer. */ +const isRefused = (model: Model): boolean => + accessOf(model) === "denied" || + (model.saveFailure !== null && isForbidden(model.saveFailure)) || + AsyncData.matchData(model.existing.data, { + onEmpty: () => false, + onFailure: isForbidden, + onData: () => false, + }) + +export const view = Submodel.defineView((model, _inputs, h) => { + const isEdit = model.editingId !== null + const header = { + // An id that is not one is still a link to a detection, not the start of a new one. + title: isEdit || model.isMissing ? "Edit detection" : "New detection", + description: "Opens an alert incident when enough matching audit events arrive within a window.", + } + const loading = dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + if (accessOf(model) === "unknown") return loading + if (isRefused(model)) + return managersOnlyPage(h, { + header, + title: "Only org admins can create or change detections", + back: BACK, + }) + if (model.isMissing) + return dashboardPage(h, header, [ + resourceNotFound(h, { + title: "Detection not found", + description: "It may have been deleted. The list shows the ones the org has now.", + back: BACK, + }), + ]) + if (isEdit && AsyncData.isFailure(model.existing.data)) + return dashboardPage(h, header, [ + errorState(h, { + failure: model.existing.data.error, + title: "Couldn't load the detection", + onRetry: Message.ClickedRetry(), + }), + ]) + if (isEdit && Option.isNone(AsyncData.getData(model.existing.data))) return loading + return dashboardPage(h, { ...header, fill: true }, [ + formScrollArea(h, [ + formCard( + h, + [ + ...(isEdit ? [] : [templateSection(h, model)]), + nameSection(h, model.form), + matchesSection(h, model.form), + conditionSection(h, model.form), + severitySection(h, model.form), + notifySection(h, model), + enabledSection(h, model.form), + ], + FORM_WIDTH, + ), + ]), + actionBar(h, model), + ]) +}) diff --git a/apps/web/src/page/security/detections/command.ts b/apps/web/src/page/security/detections/command.ts new file mode 100644 index 0000000000..5e35902b34 --- /dev/null +++ b/apps/web/src/page/security/detections/command.ts @@ -0,0 +1,37 @@ +import { AlertRuleId } from "@maple/domain/http" +import { Schema } from "effect" +import { Command } from "foldkit" +import { settleTo } from "@maple/views/data/load-failure" +import { fetchSessionGrants } from "../../kit/session-grants" +import { deleteDetection, listDetections, updateDetection } from "../kit/detection-api" +import { Message } from "./model" + +// The Detections page's reads and writes. Names start with `SecurityApi` (and the session's with +// `ReadSecurity`) so the lab drops them. + +export const ReadDetectionsGrants = fetchSessionGrants( + "ReadSecurityDetectionsGrants", + Message.CompletedReadGrants, +) + +export const FetchDetections = Command.define("SecurityApiDetectionsList", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchDetections], + execute: ({ key }) => + listDetections.pipe(settleTo((result) => Message.CompletedFetchDetections({ key, result }))), +}) + +/** Turns one detection on or off; the answer is the detection as the API stored it. */ +export const SaveDetectionEnabled = Command.define("SecurityApiDetectionToggle", { + args: { id: AlertRuleId, enabled: Schema.Boolean }, + messages: [Message.CompletedToggle], + execute: ({ id, enabled }) => + updateDetection(id, { enabled }).pipe(settleTo((result) => Message.CompletedToggle({ id, result }))), +}) + +export const DeleteDetection = Command.define("SecurityApiDetectionDelete", { + args: { id: AlertRuleId }, + messages: [Message.CompletedDelete], + execute: ({ id }) => + deleteDetection(id).pipe(settleTo((result) => Message.CompletedDelete({ id, result }))), +}) diff --git a/apps/web/src/page/security/detections/derive.ts b/apps/web/src/page/security/detections/derive.ts new file mode 100644 index 0000000000..3fa25c91c6 --- /dev/null +++ b/apps/web/src/page/security/detections/derive.ts @@ -0,0 +1,23 @@ +import type { AlertRuleId, AuditDetection } from "@maple/domain/http" +import { countLabel } from "@maple/ui/lib/format" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" +import type { Model } from "./model" + +/** The detections on screen, newest first; null until the list answers. */ +export const detectionsOf = (model: Pick): ReadonlyArray | null => + AsyncData.matchData(model.list.data, { + onEmpty: () => null, + onFailure: () => null, + onData: (page) => page.detections, + }) + +export const detectionById = ( + model: Pick, + id: AlertRuleId | null, +): Option.Option => + Arr.findFirst(detectionsOf(model) ?? [], (detection) => detection.id === id) + +/** "2 destinations", or null when the detection notifies nobody. */ +export const notifiesText = (detection: AuditDetection): string | null => + detection.destinationIds.length === 0 ? null : countLabel(detection.destinationIds.length, "destination") diff --git a/apps/web/src/page/security/detections/detections.scene.test.ts b/apps/web/src/page/security/detections/detections.scene.test.ts new file mode 100644 index 0000000000..6dc19ba868 --- /dev/null +++ b/apps/web/src/page/security/detections/detections.scene.test.ts @@ -0,0 +1,389 @@ +import { OrgId } from "@maple/domain" +import { + AuditDetectionNotFoundError, + AuditTrailForbiddenError, + AuditTrailPersistenceError, + type AlertRuleId, + type AuditDetection, + type AuditDetectionListResponse, +} from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { toLoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import { MountNetworkAutoRetry } from "@maple/views/ui/error-state" +import { MountDialog } from "@maple/views/ui/dialog" +import { Array as Arr, Option, Result, Schema } from "effect" +import { + click, + Command, + expect as expectView, + expectOutMessage, + given, + Mount, + role, + scene, + text, + within, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { detectionsOf, noDetections } from "../../../lab/security-detections-fixture" +import { type PageContext, PageOutMessage } from "../../contract" +import { DETECTION_TEMPLATES } from "../kit/detection-templates" +import { DeleteDetection, FetchDetections, SaveDetectionEnabled } from "./command" +import { detectionById, detectionsOf as shown } from "./derive" +import { Message, type Model } from "./model" +import { enter, update } from "./update" +import { view as pageView } from "./view" + +const context: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_acme"), + pinnedNamespace: null, + timeZone: "UTC", +} +const NOW = Date.UTC(2026, 9, 31, 14, 32) +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const LIST = "SecurityApiDetectionsList" + +/** The hosted product: the session, not the build, says what the viewer may read. */ +const init = () => enter(context, true) +const ADMIN = Policy.allPermissions +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const table = role("table", { name: "Detections" }) +const adminsOnly = text("Only org admins can see and change detections") + +const fold = (model: Model, messages: ReadonlyArray<(model: Model) => Message>): Model => + Arr.reduce(messages, model, (current, toMessage) => pageUpdate(current, toMessage(current)).model) + +const grantsRead = (grants: ReadonlyArray) => () => + Message.CompletedReadGrants({ orgId: context.orgId, grants: [...grants] }) + +const listed = (list: AuditDetectionListResponse) => (model: Model) => + Message.CompletedFetchDetections({ key: model.list.key ?? "", result: Result.succeed(list) }) + +const listFailed = (error: unknown) => (model: Model) => + Message.CompletedFetchDetections({ + key: model.list.key ?? "", + result: Result.fail(toLoadFailure(error)), + }) + +const LIST_OF_FIVE = detectionsOf(NOW) + +/** The page as an admin sees it once the list answered. */ +const loaded = (list: AuditDetectionListResponse = LIST_OF_FIVE): Model => + fold(init().model, [grantsRead(ADMIN), listed(list)]) + +/** The fixture's detection called `name`. */ +const named = (name: string): AuditDetection => + Option.getOrThrow(Arr.findFirst(LIST_OF_FIVE.detections, (detection) => detection.name === name)) + +const only = (name: string): Model => loaded({ detections: [named(name)] }) + +const unavailable = new AuditTrailPersistenceError({ message: "The audit trail could not be read" }) +const down = toLoadFailure(unavailable) + +describe("security detections", () => { + test("nothing is asked of the API until the session says who is looking", () => { + expect(names(init().commands)).not.toContain(LIST) + }) + + test("a self-hosted install has one role, so the list is asked for on entry", () => { + expect(names(enter(context, false).commands)).toContain(LIST) + }) + + test("an engineer is told detections are for admins and nothing is asked of the API", () => { + const answered = pageUpdate(init().model, grantsRead(ENGINEER)()) + + expect(names(answered.commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(answered.model), + expectView(adminsOnly).toExist(), + expectView(table).toBeAbsent(), + expectView(role("link", { name: "New detection" })).toBeAbsent(), + ) + }) + + // A failed session read answers with no grants at all. That is "unknown", not "member": an + // admin must not be locked out by it. + test("a session that could not be read asks the API, and the API's refusal is what locks the page", () => { + const asked = pageUpdate(init().model, grantsRead([])()) + expect(names(asked.commands)).toEqual([LIST]) + + scene( + { update: pageUpdate, view }, + given(fold(asked.model, [listed(LIST_OF_FIVE)])), + expectView(table).toExist(), + ) + scene( + { update: pageUpdate, view }, + given( + fold(asked.model, [ + listFailed( + new AuditTrailForbiddenError({ message: "Only org admins can read the audit log." }), + ), + ]), + ), + expectView(adminsOnly).toExist(), + expectView(role("button", { name: "Try again" })).toBeAbsent(), + ) + }) + + test("a row says what the detection counts, when it fires, how severe and who is told", () => { + scene( + { update: pageUpdate, view }, + given(only("Burst of refused requests")), + expectView(within(table, role("link", { name: "Burst of refused requests" }))).toExist(), + expectView(within(table, text("Denied"))).toExist(), + expectView(within(table, text("10 or more in 10 minutes"))).toExist(), + expectView(within(table, text("Critical"))).toExist(), + expectView(within(table, text("2 destinations"))).toExist(), + expectView(within(table, text("Notifies no one"))).toBeAbsent(), + ) + }) + + test("the filters read by name, and an exact action as the platform writes it", () => { + scene( + { update: pageUpdate, view }, + given(only("Role changes on Vercel")), + expectView(within(table, text("Vercel"))).toExist(), + expectView(within(table, text("team.member.role.updated"))).toExist(), + expectView(within(table, text("1 or more in 5 minutes"))).toExist(), + expectView(within(table, text("1 destination"))).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(only("Credential changes on Google Cloud")), + expectView(within(table, text("Google Cloud · Credentials"))).toExist(), + ) + }) + + test("a detection with no filters counts every audit event, over a window in hours", () => { + scene( + { update: pageUpdate, view }, + given(only("Unusual audit volume")), + expectView(within(table, text("Every audit event"))).toExist(), + expectView(within(table, text("500 or more in 1 hour"))).toExist(), + ) + }) + + // Such a detection opens incidents nobody is told about. + test("a detection that notifies no one says so", () => { + scene( + { update: pageUpdate, view }, + given(only("Credential changes on Google Cloud")), + expectView(within(table, text("Notifies no one"))).toExist(), + ) + }) + + test("the switch shows whether a detection is on, and names what a press would do", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + expectView(role("switch", { name: "Enable Repeated sign-in failures" })).toHaveAttr( + "aria-checked", + "false", + ), + expectView(role("switch", { name: "Disable Role changes on Vercel" })).toHaveAttr( + "aria-checked", + "true", + ), + ) + }) + + test("members are told once what they see of a detection", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + expectView( + text( + "Every member of the org can see a detection's name and that it fired. Only org admins see the events it counted.", + ), + ).toExist(), + ) + }) + + test("the switch saves on toggle and shows what the API stored", () => { + const detection = named("Role changes on Vercel") + + scene( + { update: pageUpdate, view }, + given(loaded()), + click(role("switch", { name: "Disable Role changes on Vercel" })), + Command.resolve( + SaveDetectionEnabled({ id: detection.id, enabled: false }), + Message.CompletedToggle({ + id: detection.id, + result: Result.succeed({ ...detection, enabled: false }), + }), + ), + expectView(role("switch", { name: "Enable Role changes on Vercel" })).toHaveAttr( + "aria-checked", + "false", + ), + ) + }) + + // No optimistic flip: until the API answers the switch is where it was, and cannot be pressed again. + test("a toggle in flight leaves the switch as it was, and a second press sends nothing", () => { + const detection = named("Role changes on Vercel") + const pressed = pageUpdate(loaded(), Message.ToggledDetection({ id: detection.id })) + + expect(names(pressed.commands)).toEqual(["SecurityApiDetectionToggle"]) + expect( + names(pageUpdate(pressed.model, Message.ToggledDetection({ id: detection.id })).commands), + ).toEqual([]) + expect(detectionById(pressed.model, detection.id)).toEqual(Option.some(detection)) + }) + + test("a toggle the API refuses leaves the detection as it was and says why", () => { + const detection = named("Role changes on Vercel") + const pressed = pageUpdate(loaded(), Message.ToggledDetection({ id: detection.id })).model + const failed = pageUpdate( + pressed, + Message.CompletedToggle({ id: detection.id, result: Result.fail(down) }), + ) + + expect(failed.outMessage).toMatchObject({ type: "error", title: "Couldn't update the detection" }) + scene( + { update: pageUpdate, view }, + given(failed.model), + expectView(role("switch", { name: "Disable Role changes on Vercel" })).toHaveAttr( + "aria-checked", + "true", + ), + ) + }) + + const confirm = within(role("alertdialog"), role("button", { name: "Delete detection" })) + const askedToDelete = (id: AlertRuleId): Model => + pageUpdate(loaded(), Message.ClickedDelete({ id })).model + + test("delete asks first, and says the detection's incidents go with it", () => { + const detection = named("Burst of refused requests") + const asked = pageUpdate(loaded(), Message.ClickedDelete({ id: detection.id })) + + expect(names(asked.commands)).not.toContain("SecurityApiDetectionDelete") + scene( + { update: pageUpdate, view }, + given(asked.model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(within(role("alertdialog"), text("Delete Burst of refused requests?"))).toExist(), + expectView( + within(role("alertdialog"), text(/the incidents it opened are deleted with it/u)), + ).toExist(), + expectView(confirm).toExist(), + ) + }) + + test("a confirmed delete removes the detection once the API says it is gone", () => { + const detection = named("Burst of refused requests") + const confirmed = pageUpdate(askedToDelete(detection.id), Message.ClickedConfirmDelete()) + + expect(confirmed.commands?.[0]).toMatchObject({ + name: DeleteDetection.name, + args: { id: detection.id }, + }) + // Still listed while the request is out. + expect(shown(confirmed.model)).toHaveLength(LIST_OF_FIVE.detections.length) + + const deleted = pageUpdate( + confirmed.model, + Message.CompletedDelete({ id: detection.id, result: Result.succeed({ id: detection.id }) }), + ) + expect(deleted.outMessage).toEqual( + PageOutMessage.RequestedToast({ type: "success", title: "Detection deleted", description: "" }), + ) + expect(deleted.model.deleteDialog.isOpen).toBe(false) + expect(Arr.map(shown(deleted.model) ?? [], (row) => row.name)).not.toContain(detection.name) + }) + + test("a delete the API refuses keeps the detection listed and the dialog open", () => { + const detection = named("Burst of refused requests") + const confirmed = pageUpdate(askedToDelete(detection.id), Message.ClickedConfirmDelete()).model + const failed = pageUpdate( + confirmed, + Message.CompletedDelete({ + id: detection.id, + result: Result.fail( + toLoadFailure( + new AuditDetectionNotFoundError({ + detectionId: detection.id, + message: "No such detection.", + }), + ), + ), + }), + ) + + expect(failed.outMessage).toMatchObject({ type: "error", title: "Couldn't delete the detection" }) + expect(failed.model.deleteDialog.isOpen).toBe(true) + expect(shown(failed.model)).toHaveLength(LIST_OF_FIVE.detections.length) + }) + + test("edit opens the editor on that detection", () => { + const detection = named("Burst of refused requests") + + scene( + { update: pageUpdate, view }, + given(loaded()), + click(role("button", { name: "Edit Burst of refused requests" })), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: `/security/detections/new?detectionId=${detection.id}`, + isReplace: false, + }), + ), + ) + }) + + test("with no detections the page offers the ready-made ones, each opening the editor filled in", () => { + expect(DETECTION_TEMPLATES).toHaveLength(5) + scene( + { update: pageUpdate, view }, + given(loaded(noDetections)), + expectView(text("No detections yet")).toExist(), + expectView(table).toBeAbsent(), + ...Arr.map(DETECTION_TEMPLATES, (template) => + expectView(role("link", { name: `Use template: ${template.title}` })).toHaveAttr( + "href", + `/security/detections/new?template=${template.id}`, + ), + ), + expectView(text("Fires on 5 or more failed sign-ins within 10 minutes.")).toExist(), + expectView(role("link", { name: "Start from scratch" })).toHaveAttr( + "href", + "/security/detections/new", + ), + expectView(role("link", { name: "New detection" })).toHaveAttr( + "href", + "/security/detections/new", + ), + ) + }) + + test("a failed load says so, and trying again shows the list it gets", () => { + const failed = fold(init().model, [grantsRead(ADMIN), listFailed(unavailable)]) + const key = failed.list.key ?? "" + + scene( + { update: pageUpdate, view }, + given(failed), + expectView(text("Couldn't load detections")).toExist(), + expectView(table).toBeAbsent(), + Mount.resolve( + MountNetworkAutoRetry({ retryKey: JSON.stringify(Message.ClickedRetry()) }), + Message.ClickedRetry(), + ), + Command.resolve( + FetchDetections({ key }), + Message.CompletedFetchDetections({ key, result: Result.succeed(LIST_OF_FIVE) }), + ), + Mount.expectEnded(MountNetworkAutoRetry), + expectView(table).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/security/detections/index.ts b/apps/web/src/page/security/detections/index.ts new file mode 100644 index 0000000000..52da886c5a --- /dev/null +++ b/apps/web/src/page/security/detections/index.ts @@ -0,0 +1,19 @@ +import { definePage } from "../../contract" +import { breadcrumbsOf } from "../kit/layout" +import { Message, Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +/** `/security/detections`, in the Security frame: the alert rules that count audit events, for org admins. */ +export const page = definePage( + "SecurityDetections", + { Model, Message }, + { + routes: ["SecurityDetections"], + key: () => "security-detections", + init, + update, + view, + breadcrumbs: () => breadcrumbsOf("detections"), + }, +) diff --git a/apps/web/src/page/security/detections/model.ts b/apps/web/src/page/security/detections/model.ts new file mode 100644 index 0000000000..70fed6bde2 --- /dev/null +++ b/apps/web/src/page/security/detections/model.ts @@ -0,0 +1,48 @@ +import { + AlertRuleId, + AuditDetection, + AuditDetectionDeleteResponse, + AuditDetectionListResponse, +} from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import { LoadOutcome } from "../../kit/load-schemas" +import { SessionGrants } from "../../kit/session-grants" + +// `/security/detections`: the org's audit log detections, each an alert rule that counts audit +// events. Only org admins (`audit_log:read`) read or change them. + +export const Model = Schema.Struct({ + grants: Schema.Array(Policy.PermissionName), + /** False until the session answers: the page says nothing about access before it knows. */ + isGrantsRead: Schema.Boolean, + /** The list as the API last gave it; a saved toggle or a delete writes the API's answer into it. */ + list: KeyedSchema(AuditDetectionListResponse, LoadFailure), + /** The detections whose switch has a save in flight. */ + togglingIds: Schema.Array(AlertRuleId), + /** The detection the delete dialog asks about. */ + confirmDeleteId: Schema.NullOr(AlertRuleId), + deletingId: Schema.NullOr(AlertRuleId), + deleteDialog: Dialog.Model, +}) +export type Model = typeof Model.Type + +export { auditAccessOf as accessOf } from "../kit/audit-access" + +export const Message = defineMessageUnion({ + CompletedReadGrants: SessionGrants, + CompletedFetchDetections: { key: Schema.String, result: LoadOutcome(AuditDetectionListResponse) }, + ClickedRetry: {}, + ClickedEdit: { id: AlertRuleId }, + ToggledDetection: { id: AlertRuleId }, + CompletedToggle: { id: AlertRuleId, result: LoadOutcome(AuditDetection) }, + ClickedDelete: { id: AlertRuleId }, + GotDeleteDialogMessage: { message: Dialog.Message }, + ClickedConfirmDelete: {}, + CompletedDelete: { id: AlertRuleId, result: LoadOutcome(AuditDetectionDeleteResponse) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/security/detections/update.ts b/apps/web/src/page/security/detections/update.ts new file mode 100644 index 0000000000..3cd7a7c2f2 --- /dev/null +++ b/apps/web/src/page/security/detections/update.ts @@ -0,0 +1,181 @@ +import type { AlertRuleId, AuditDetection } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import * as Keyed from "@maple/views/data/keyed" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { requestKey } from "@maple/views/data/request-key" +import * as Dialog from "@maple/views/ui/dialog" +import { Array as Arr, Option, Result } from "effect" +import { Command as FoldkitCommand } from "foldkit" +import { isClerkAuthEnabled } from "../../../platform/auth-mode" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { chain, slotStep } from "../../kit/keyed-step" +import { acceptGrants } from "../../kit/session-grants" +import { detectionFormHref } from "../kit/detection-format" +import { DeleteDetection, FetchDetections, ReadDetectionsGrants, SaveDetectionEnabled } from "./command" +import { detectionById, detectionsOf } from "./derive" +import { accessOf, Message, type Model } from "./model" + +type Return = PageReturn + +type SecurityDetectionsRoute = Extract + +const DELETE_DIALOG_ID = "security-detection-delete" + +const keyOf = (context: PageContext) => requestKey(context.orgId, "auditTrail.listDetections", {}) + +/** The list, unless the session said the viewer may not read it. */ +const readList = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + accessOf(model) !== "ask" + ? { model } + : slotStep( + model, + model.list, + (list) => ({ list }), + keyOf(context), + mode, + (key) => FetchDetections({ key }), + ) + +/** + * Entering the page. `sessionDecides`: the hosted product, where the session says what the viewer + * may read and nothing is asked before it answers. Self-hosted has one role and reads at once. + */ +export const enter = (context: PageContext, sessionDecides: boolean): Return => { + const model: Model = { + grants: sessionDecides ? [] : Policy.allPermissions, + isGrantsRead: !sessionDecides, + list: Keyed.idle(), + togglingIds: [], + confirmDeleteId: null, + deletingId: null, + deleteDialog: Dialog.init(DELETE_DIALOG_ID, "alert-dialog"), + } + const entered: Return = { + model, + commands: sessionDecides ? [ReadDetectionsGrants({ orgId: context.orgId })] : [], + } + return chain(entered, (next) => readList(next, context, "Load")) +} + +export const init = (_route: SecurityDetectionsRoute, context: PageContext): Return => + enter(context, isClerkAuthEnabled) + +const errorToast = (title: string, failure: LoadFailure): PageOutMessage => + PageOutMessage.RequestedToast({ type: "error", title, description: failure.message }) + +/** The list with `change` applied to the rows on screen: what the API answered, never a guess. */ +const withRows = ( + model: Model, + change: (rows: ReadonlyArray) => ReadonlyArray, +): Model => { + const rows = detectionsOf(model) + return rows === null || model.list.key === null + ? model + : { + ...model, + list: Keyed.accept(model.list, model.list.key, Result.succeed({ detections: change(rows) })), + } +} + +const withoutId = (ids: ReadonlyArray, id: AlertRuleId): ReadonlyArray => + Arr.filter(ids, (existing) => existing !== id) + +/** The switch saves at once; it stays where it was until the API says what is stored. */ +const toggle = (model: Model, id: AlertRuleId): Return => + Option.match(detectionById(model, id), { + onNone: (): Return => ({ model }), + onSome: (detection): Return => + Arr.contains(model.togglingIds, id) + ? { model } + : { + model: { ...model, togglingIds: [...model.togglingIds, id] }, + commands: [SaveDetectionEnabled({ id, enabled: !detection.enabled })], + }, + }) + +const stepDeleteDialog = (model: Model, step: ReturnType): Return => ({ + model: { + ...model, + deleteDialog: step.model, + confirmDeleteId: step.outMessage?._tag === "Closed" ? null : model.confirmDeleteId, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteDialogMessage({ message }), + ), +}) + +const confirmDelete = (model: Model): Return => + model.confirmDeleteId === null || model.deletingId !== null + ? { model } + : { + model: { ...model, deletingId: model.confirmDeleteId }, + commands: [DeleteDetection({ id: model.confirmDeleteId })], + } + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + CompletedReadGrants: (answer) => + readList(acceptGrants(model, context.orgId, answer, { isGrantsRead: true }), context, "Load"), + CompletedFetchDetections: ({ key, result }) => ({ + model: { ...model, list: Keyed.accept(model.list, key, result) }, + }), + ClickedRetry: () => readList(model, context, "Refresh"), + ClickedEdit: ({ id }) => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: detectionFormHref({ detectionId: id }), + isReplace: false, + }), + }), + ToggledDetection: ({ id }) => toggle(model, id), + CompletedToggle: ({ id, result }) => { + const settled = { ...model, togglingIds: withoutId(model.togglingIds, id) } + return Result.match(result, { + onSuccess: (saved): Return => ({ + model: withRows(settled, (rows) => + Arr.map(rows, (row) => (row.id === saved.id ? saved : row)), + ), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't update the detection", failure), + }), + }) + }, + ClickedDelete: ({ id }) => + Option.match(detectionById(model, id), { + onNone: (): Return => ({ model }), + onSome: (): Return => + stepDeleteDialog({ ...model, confirmDeleteId: id }, Dialog.open(model.deleteDialog)), + }), + GotDeleteDialogMessage: ({ message: child }) => + // A delete in flight keeps its dialog: closing it would hide whether it went through. + model.deletingId !== null + ? { model } + : stepDeleteDialog(model, Dialog.update(model.deleteDialog, child)), + ClickedConfirmDelete: () => confirmDelete(model), + CompletedDelete: ({ result }) => { + const settled = { ...model, deletingId: null } + return Result.match(result, { + onSuccess: (deleted): Return => { + const closed = stepDeleteDialog(settled, Dialog.close(settled.deleteDialog)) + return { + ...closed, + model: withRows(closed.model, (rows) => + Arr.filter(rows, (row) => row.id !== deleted.id), + ), + outMessage: PageOutMessage.RequestedToast({ + type: "success", + title: "Detection deleted", + description: "", + }), + } + }, + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't delete the detection", failure), + }), + }) + }, + }) diff --git a/apps/web/src/page/security/detections/view.ts b/apps/web/src/page/security/detections/view.ts new file mode 100644 index 0000000000..39cb7b177a --- /dev/null +++ b/apps/web/src/page/security/detections/view.ts @@ -0,0 +1,329 @@ +import type { AuditDetection } from "@maple/domain/http" +import { refreshingClass } from "@maple/ui/lib/refreshing" +import * as Keyed from "@maple/views/data/keyed" +import { Array as Arr, Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { + empty, + emptyDescription, + emptyFrameClass, + emptyHeader, + emptyMedia, + emptyTitle, +} from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { item, itemActions, itemContent, itemDescription, itemTitle } from "@maple/views/ui/item" +import { panel } from "@maple/views/ui/panel" +import { emptyActions } from "@maple/views/ui/signal-empty-state" +import { hstack, vstack } from "@maple/views/ui/stack" +import { switchControl } from "@maple/views/ui/switch" +import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/views/ui/table" +import { textLink } from "@maple/views/ui/text-link" +import { truncatedText } from "@maple/views/ui/truncated-text" +import { typography, typographyClass } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { severityBadge } from "../../alerts/shared/badges" +import { + conditionText, + detectionFormHref, + EVERY_EVENT, + isForbidden, + matchText, +} from "../kit/detection-format" +import { DETECTION_TEMPLATES, type DetectionTemplate } from "../kit/detection-templates" +import { BELOW_LAPTOP, LAPTOP_COLUMN, securityLayout } from "../kit/layout" +import { placeholderRows } from "../kit/parts" +import { detectionById, detectionsOf, notifiesText } from "./derive" +import { accessOf, Message, type Model } from "./model" + +type H = HtmlBuilder + +// Below a laptop's width the name column takes what the hidden ones leave, and a long name wraps. +const NARROW_NAME = + "max-w-44 whitespace-normal sm:max-w-none min-[1360px]:max-w-72 min-[1360px]:whitespace-nowrap" + +/** What the detection counts: the filters by name, then the exact action as the platform writes it. */ +const matchLine = (h: H, detection: AuditDetection): Html => { + const labels = matchText(detection.filters) + const { action } = detection.filters + const small = typographyClass({ size: "xs", tone: "muted" }) + if (labels === "" && action === undefined) + return typography(h, { size: "xs", tone: "muted", truncate: true }, [EVERY_EVENT]) + // The names stay whole; a long action is what gives way, onto its own line on a phone. + return hstack(h, { gap: "1.5", align: "center", wrap: true, shrink: true }, [ + ...(labels === "" ? [] : [typography(h, { size: "xs", tone: "muted" }, [labels])]), + ...(labels === "" || action === undefined + ? [] + : [ + typography( + h, + // A phone wraps the action onto its own line, where a trailing dot would dangle. + { + size: "xs", + tone: "faint", + className: "hidden sm:inline", + attributes: [h.AriaHidden(true)], + }, + ["·"], + ), + ]), + ...(action === undefined + ? [] + : [ + truncatedText(h, { + text: action, + mono: true, + tooltip: "overflow", + className: `max-w-full ${small}`, + }), + ]), + ]) +} + +/** Who is told when it fires. Nobody is a state worth seeing: the incident opens and no one hears. */ +const notifies = (h: H, detection: AuditDetection): Html => { + const text = notifiesText(detection) + return text === null + ? hstack( + h, + { gap: "1.5", align: "center", className: typographyClass({ size: "xs", tone: "warning" }) }, + [icon(h, "BellSlashIcon", { size: 14 }), "Notifies no one"], + ) + : typography(h, { size: "xs", tone: "muted" }, [text]) +} + +const detectionRow = (h: H, model: Model, detection: AuditDetection): Html => { + const condition = conditionText(detection.threshold, detection.windowMinutes) + const isBusy = Arr.contains(model.togglingIds, detection.id) || model.deletingId === detection.id + return tableRow( + h, + [ + tableCell( + h, + [ + vstack(h, { gap: "0.5", shrink: true }, [ + textLink( + h, + { + tone: "inherit", + href: detectionFormHref({ detectionId: detection.id }), + className: typographyClass({ + size: "sm", + weight: "medium", + className: "block break-words min-[1360px]:truncate", + }), + }, + [detection.name], + ), + matchLine(h, detection), + // Without the Condition, Severity and Notifies columns they ride under the name. + hstack(h, { gap: "2", align: "center", wrap: true, className: BELOW_LAPTOP }, [ + typography(h, { size: "xs", tone: "muted" }, [condition]), + severityBadge(h, detection.severity), + // No column heading down here, so the count says what it counts. + detection.destinationIds.length === 0 + ? notifies(h, detection) + : typography(h, { size: "xs", tone: "muted" }, [ + `Notifies ${notifiesText(detection) ?? ""}`, + ]), + ]), + ]), + ], + NARROW_NAME, + ), + tableCell(h, [typography(h, { size: "xs", numeric: true }, [condition])], LAPTOP_COLUMN), + tableCell(h, [severityBadge(h, detection.severity)], LAPTOP_COLUMN), + tableCell(h, [notifies(h, detection)], LAPTOP_COLUMN), + tableCell(h, [ + hstack(h, { gap: "1", align: "center", justify: "end" }, [ + ...switchControl(h, { + id: `detection-switch-${detection.id}`, + inputId: `detection-switch-input-${detection.id}`, + isChecked: detection.enabled, + isDisabled: isBusy, + ariaLabel: `${detection.enabled ? "Disable" : "Enable"} ${detection.name}`, + className: "mr-2", + onToggle: Message.ToggledDetection({ id: detection.id }), + }), + iconButton( + h, + { + label: `Edit ${detection.name}`, + attributes: [h.OnClick(Message.ClickedEdit({ id: detection.id }))], + }, + [icon(h, "PencilIcon")], + ), + iconButton( + h, + { + label: `Delete ${detection.name}`, + isDisabled: isBusy, + attributes: [h.OnClick(Message.ClickedDelete({ id: detection.id }))], + }, + [icon(h, "TrashIcon")], + ), + ]), + ]), + ], + undefined, + [h.Key(detection.id)], + ) +} + +const detectionsTable = (h: H, model: Model, detections: ReadonlyArray): Html => + table(h, { ariaLabel: "Detections" }, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, ["Detection"]), + tableHead(h, ["Fires on"], `w-44 ${LAPTOP_COLUMN}`), + tableHead(h, ["Severity"], `w-24 ${LAPTOP_COLUMN}`), + tableHead(h, ["Notifies"], `w-36 ${LAPTOP_COLUMN}`), + tableHead(h, [typography(h, { className: "sr-only" }, ["Enabled and actions"])], "w-36"), + ]), + ]), + tableBody( + h, + Arr.map(detections, (detection) => detectionRow(h, model, detection)), + ), + ]) + +/** One ready-made detection: what it is, when it fires, and the editor filled in with it. */ +const templateCard = (h: H, template: DetectionTemplate): Html => + item(h, { variant: "card", size: "lg", className: "text-left", tag: "li" }, [ + itemContent(h, [itemTitle(h, [template.title]), itemDescription(h, [template.summary])]), + itemActions(h, [ + linkButton( + h, + { + variant: "outline", + size: "sm", + href: detectionFormHref({ template: template.id }), + attributes: [h.AriaLabel(`Use template: ${template.title}`)], + }, + ["Use template"], + ), + ]), + ]) + +/** Nothing set up yet: the common detections to start from, so the page is never a blank slate. */ +const onRamp = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "RadarIcon")]), + emptyTitle(h, ["No detections yet"]), + emptyDescription(h, [ + "Start from a common one. It opens in the editor filled in, and nothing is created until you save.", + ]), + ]), + vstack( + h, + { as: "ul", gap: "2", className: "w-full max-w-2xl" }, + Arr.map(DETECTION_TEMPLATES, (template) => templateCard(h, template)), + ), + emptyActions(h, [ + linkButton(h, { variant: "ghost", size: "sm", href: detectionFormHref() }, [ + icon(h, "PlusIcon"), + "Start from scratch", + ]), + ]), + ], + "py-12", + ) + +/** Not an org admin: the page says who can read it. */ +const adminsOnly = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "LockIcon")]), + emptyTitle(h, ["Only org admins can see and change detections"]), + emptyDescription(h, [ + "A detection counts audit events, which record who did what. You still see its incidents in Alerts.", + ]), + ]), + ], + emptyFrameClass("dashed"), + ) + +/** What a member sees of a detection, said once: its rule row syncs to every member's browser. */ +const footnote = (h: H): Html => + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "Every member of the org can see a detection's name and that it fired. Only org admins see the events it counted.", + ]) + +const listBody = (h: H, model: Model): ReadonlyArray => + AsyncData.matchData(model.list.data, { + onEmpty: () => [placeholderRows(h, 4)], + onFailure: (failure) => [ + errorState(h, { + failure, + title: "Couldn't load detections", + onRetry: Message.ClickedRetry(), + }), + ], + onData: () => { + const detections = detectionsOf(model) ?? [] + if (detections.length === 0) return [onRamp(h), footnote(h)] + const isRefetching = Keyed.isInFlight(model.list) + return [ + panel( + h, + { + className: `overflow-hidden ${refreshingClass(isRefetching)}`, + attributes: [h.AriaBusy(isRefetching)], + }, + [detectionsTable(h, model, detections)], + ), + footnote(h), + ] + }, + }) + +const deleteDialog = (h: H, model: Model): Html => { + const name = Option.match(detectionById(model, model.confirmDeleteId), { + onNone: () => "this detection", + onSome: (detection) => detection.name, + }) + return confirmDialog(h, { + slotId: "security-detection-delete", + model: model.deleteDialog, + toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), + title: `Delete ${name}?`, + description: + "It stops counting audit events, and the incidents it opened are deleted with it. This cannot be undone.", + confirmLabel: "Delete detection", + isPending: model.deletingId !== null, + onConfirm: Message.ClickedConfirmDelete(), + }) +} + +/** The session, or the API when the session could not say, refused the viewer. */ +const isRefused = (model: Model): boolean => + accessOf(model) === "denied" || + AsyncData.matchData(model.list.data, { + onEmpty: () => false, + onFailure: isForbidden, + onData: () => false, + }) + +const newDetectionButton = (h: H): Html => + linkButton(h, { size: "sm", href: detectionFormHref() }, [icon(h, "PlusIcon"), "New detection"]) + +export const view = Submodel.defineView((model, _inputs, h) => { + if (accessOf(model) === "unknown") + return securityLayout(h, { source: "detections" }, [placeholderRows(h, 4)]) + if (isRefused(model)) return securityLayout(h, { source: "detections" }, [adminsOnly(h)]) + return securityLayout( + h, + { source: "detections", actions: newDetectionButton(h), overlay: [deleteDialog(h, model)] }, + listBody(h, model), + ) +}) diff --git a/apps/web/src/page/security/kit/audit-access.ts b/apps/web/src/page/security/kit/audit-access.ts new file mode 100644 index 0000000000..b19f513897 --- /dev/null +++ b/apps/web/src/page/security/kit/audit-access.ts @@ -0,0 +1,20 @@ +import * as Policy from "@maple/domain/policy" + +// Who may read the audit trail and its detections: org admins (`audit_log:read`). + +type SessionAnswer = Readonly<{ + grants: ReadonlyArray + /** False until the session answers: a page says nothing about access before it knows. */ + isGrantsRead: boolean +}> + +/** + * What the session says: `denied` only when it answered and the permission is missing. A session + * that answered with nothing failed to read, so the API is asked and gets the last word. + */ +export const auditAccessOf = (model: SessionAnswer): "unknown" | "ask" | "denied" => + !model.isGrantsRead + ? "unknown" + : model.grants.length === 0 || Policy.has(model.grants, "audit_log:read") + ? "ask" + : "denied" diff --git a/apps/web/src/page/security/kit/audit-labels.ts b/apps/web/src/page/security/kit/audit-labels.ts new file mode 100644 index 0000000000..03782ff683 --- /dev/null +++ b/apps/web/src/page/security/kit/audit-labels.ts @@ -0,0 +1,45 @@ +import type { AuditTrailCategory, AuditTrailOutcome, AuditTrailProvider } from "@maple/domain/audit-trail" +import { Option, Record as Rec } from "effect" + +// The names of audit platforms, categories and outcomes, shared by the audit log and the +// detections that count its events. A value this build does not know shows as itself. + +export const PROVIDER_LABEL = { + maple: "Maple", + vercel: "Vercel", + gcp: "Google Cloud", +} satisfies Record + +export const CATEGORY_LABEL = { + authentication: "Authentication", + membership: "Membership", + permission: "Permissions", + credential: "Credentials", + configuration: "Configuration", + resource: "Resources", + data_access: "Data access", + billing: "Billing", + other: "Other", +} satisfies Record + +export const OUTCOME_LABEL = { + success: "Succeeded", + failure: "Failed", + denied: "Denied", + unknown: "Unknown", +} satisfies Record + +/** The outcomes worth filtering by. `unknown` is a platform that did not say, not something to look for. */ +export const OUTCOME_FILTERS = [ + "success", + "failure", + "denied", +] as const satisfies ReadonlyArray + +/** The label for `value`, or the value itself when this build has no name for it. */ +export const labelIn = (labels: Readonly>, value: string): string => + Option.getOrElse(Rec.get(labels, value), () => value) + +export const providerLabel = (provider: string): string => labelIn(PROVIDER_LABEL, provider) +export const categoryLabel = (category: string): string => labelIn(CATEGORY_LABEL, category) +export const outcomeLabel = (outcome: string): string => labelIn(OUTCOME_LABEL, outcome) diff --git a/apps/web/src/page/security/kit/detection-api.ts b/apps/web/src/page/security/kit/detection-api.ts new file mode 100644 index 0000000000..22a5c7be7d --- /dev/null +++ b/apps/web/src/page/security/kit/detection-api.ts @@ -0,0 +1,26 @@ +import type { + AlertRuleId, + AuditDetectionCreateRequest, + AuditDetectionUpdateRequest, +} from "@maple/domain/http" +import { Effect } from "effect" +import { MapleInternalApiClient } from "../../../platform/api-clients" + +// The audit log detections on the api Worker. All four need `audit_log:read`: a member gets +// `audit_trail_forbidden`. + +/** Every detection of the org, newest first. One read: the API caps an org at 50. */ +export const listDetections = Effect.flatMap(MapleInternalApiClient, (api) => api.auditTrail.listDetections()) + +export const createDetection = (payload: AuditDetectionCreateRequest) => + Effect.flatMap(MapleInternalApiClient, (api) => api.auditTrail.createDetection({ payload })) + +/** Only the fields sent change; the answer is the detection as stored. */ +export const updateDetection = (id: AlertRuleId, payload: AuditDetectionUpdateRequest) => + Effect.flatMap(MapleInternalApiClient, (api) => + api.auditTrail.updateDetection({ params: { id }, payload }), + ) + +/** Deletes the detection's rule, and its incidents with it. */ +export const deleteDetection = (id: AlertRuleId) => + Effect.flatMap(MapleInternalApiClient, (api) => api.auditTrail.deleteDetection({ params: { id } })) diff --git a/apps/web/src/page/security/kit/detection-format.ts b/apps/web/src/page/security/kit/detection-format.ts new file mode 100644 index 0000000000..8f9675e463 --- /dev/null +++ b/apps/web/src/page/security/kit/detection-format.ts @@ -0,0 +1,39 @@ +import type { AuditDetectionFilters } from "@maple/domain/audit-trail" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { countLabel } from "@maple/ui/lib/format" +import { Array as Arr } from "effect" +import { AppRoute, hrefOf } from "../../../route" +import type { SecurityDetectionNewSearch } from "../../../route/search" +import { categoryLabel, outcomeLabel, providerLabel } from "./audit-labels" + +// A detection in words: what it counts and when it fires. The list, the editor and the ready-made +// cards all say it the same way. + +export const EVERY_EVENT = "Every audit event" + +/** The platform, category and outcome a detection counts, by name; empty when it counts them all. */ +const matchLabels = (filters: AuditDetectionFilters): ReadonlyArray => [ + ...(filters.provider === undefined ? [] : [providerLabel(filters.provider)]), + ...(filters.category === undefined ? [] : [categoryLabel(filters.category)]), + ...(filters.outcome === undefined ? [] : [outcomeLabel(filters.outcome)]), +] + +/** "Vercel · Credentials · Denied", without the action: that one is shown in mono beside it. */ +export const matchText = (filters: AuditDetectionFilters): string => Arr.join(matchLabels(filters), " · ") + +/** "10 minutes", or "2 hours" for a window of whole hours. */ +const windowText = (minutes: number): string => + minutes >= 60 && minutes % 60 === 0 ? countLabel(minutes / 60, "hour") : countLabel(minutes, "minute") + +/** "5 or more in 10 minutes". */ +export const conditionText = (threshold: number, windowMinutes: number): string => + `${threshold.toLocaleString("en-US")} or more in ${windowText(windowMinutes)}` + +/** The API refused the viewer: only org admins hold `audit_log:read`. */ +export const isForbidden = (failure: LoadFailure): boolean => failure.code === "audit_trail_forbidden" + +export const detectionsHref = (): string => hrefOf(AppRoute.SecurityDetections()) + +/** The editor: empty, prefilled, or on one detection (`detectionId`). */ +export const detectionFormHref = (search: SecurityDetectionNewSearch = {}): string => + hrefOf(AppRoute.SecurityDetectionNew({ search })) diff --git a/apps/web/src/page/security/kit/detection-templates.ts b/apps/web/src/page/security/kit/detection-templates.ts new file mode 100644 index 0000000000..1c2feab8e7 --- /dev/null +++ b/apps/web/src/page/security/kit/detection-templates.ts @@ -0,0 +1,72 @@ +import type { AuditDetectionFilters } from "@maple/domain/audit-trail" +import type { AlertSeverity } from "@maple/domain/http" +import { Array as Arr, Option } from "effect" + +// The ready-made detections the empty Detections page and the editor start from. Picking one +// fills the form; nothing is created until it is saved. + +type DetectionTemplateId = "membership" | "permission" | "credential" | "refused" | "sign_in" + +export type DetectionTemplate = Readonly<{ + id: DetectionTemplateId + /** Also the name the form starts with. */ + title: string + /** When it fires, in one line. */ + summary: string + filters: AuditDetectionFilters + threshold: number + windowMinutes: number + severity: AlertSeverity +}> + +export const DETECTION_TEMPLATES: ReadonlyArray = [ + { + id: "membership", + title: "Member added or removed", + summary: "Fires when anyone is invited, added or removed on any platform.", + filters: { category: "membership" }, + threshold: 1, + windowMinutes: 5, + severity: "warning", + }, + { + id: "permission", + title: "Permission or role changed", + summary: "Fires when a role or permission change goes through.", + filters: { category: "permission", outcome: "success" }, + threshold: 1, + windowMinutes: 5, + severity: "warning", + }, + { + id: "credential", + title: "Credential created, rotated or revoked", + summary: "Fires when an API key, token or secret changes.", + filters: { category: "credential" }, + threshold: 1, + windowMinutes: 5, + severity: "warning", + }, + { + id: "refused", + title: "Burst of refused requests", + summary: "Fires on 10 or more denied requests within 10 minutes.", + filters: { outcome: "denied" }, + threshold: 10, + windowMinutes: 10, + severity: "critical", + }, + { + id: "sign_in", + title: "Repeated sign-in failures", + summary: "Fires on 5 or more failed sign-ins within 10 minutes.", + filters: { category: "authentication", outcome: "failure" }, + threshold: 5, + windowMinutes: 10, + severity: "critical", + }, +] + +/** The template a URL names, if this build has it. */ +export const templateOf = (id: string | undefined): Option.Option => + Arr.findFirst(DETECTION_TEMPLATES, (template) => template.id === id) diff --git a/apps/web/src/page/security/kit/layout.ts b/apps/web/src/page/security/kit/layout.ts index 84a801686c..21faa76f0a 100644 --- a/apps/web/src/page/security/kit/layout.ts +++ b/apps/web/src/page/security/kit/layout.ts @@ -10,7 +10,7 @@ import { vstack } from "@maple/views/ui/stack" // The frame of each Security source's page: the rail of sources, the source's title block, content. // The code security audit is the one source with tabs of its own (Overview, Findings, Runs). -export type SecuritySource = "code" | "dependencies" | "runtimes" | "exposure" | "auditLog" +export type SecuritySource = "code" | "dependencies" | "runtimes" | "exposure" | "auditLog" | "detections" export type CodeTab = "overview" | "findings" | "runs" const SOURCE_LABEL = { @@ -19,6 +19,7 @@ const SOURCE_LABEL = { runtimes: "Runtimes", exposure: "Exposure", auditLog: "Audit log", + detections: "Detections", } satisfies Record const SECTIONS: ReadonlyArray> = [ @@ -67,6 +68,13 @@ const SECTIONS: ReadonlyArray> = [ icon: "HistoryIcon", href: hrefOf(AppRoute.SecurityAuditLog({ search: {} })), }, + { + kind: "link", + id: "detections", + label: SOURCE_LABEL.detections, + icon: "RadarIcon", + href: hrefOf(AppRoute.SecurityDetections()), + }, ], }, ] @@ -125,6 +133,12 @@ export const codeCrumbs = (back: BreadcrumbEntry): ReadonlyArray => [ + SECURITY_CRUMB, + { label: SOURCE_LABEL.detections, href: hrefOf(AppRoute.SecurityDetections()) }, +] + const codeTabs = (h: HtmlBuilder, active: CodeTab): Html => linkTabStrip( h, @@ -148,6 +162,8 @@ const DESCRIPTION = { "Known vulnerable packages in your connected repositories' lockfiles, rescanned on every push.", auditLog: "Who changed what, and what was refused, in Maple and your connected platforms. Visible to org admins.", + detections: + "A detection opens an alert incident when enough matching audit events arrive within its window.", } satisfies Record /** The page's blocks at their own height: the scroll area scrolls them, never squeezes them. */ @@ -187,5 +203,13 @@ export const NARROW_TWO_COLUMNS = "md:grid-cols-2 xl:grid-cols-4 md:divide-y xl: /** A table column phones drop; what they still need from it rides in the row's first cell. */ export const WIDE_COLUMN = "hidden sm:table-cell" +/** + * A column a laptop screen has room for beside the sidebar and the rail. Below that, what it + * says rides in the row's first cell, so the row's controls stay on screen. + */ +export const LAPTOP_COLUMN = "hidden min-[1360px]:table-cell" +/** The first cell's stand-in for the `LAPTOP_COLUMN`s, shown wherever they are not. */ +export const BELOW_LAPTOP = "min-[1360px]:hidden" + /** A column only a wide screen has room for beside the rail. */ export const ROOMY_COLUMN = "hidden min-[1680px]:table-cell" diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 693a1a80f1..d7d9387e99 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -61,6 +61,8 @@ export const AppRoute = defineRouteUnion({ SecurityRuntimes: {}, SecurityDependencies: { search: Search.SecurityDependenciesSearch }, SecurityAuditLog: { search: Search.SecurityAuditLogSearch }, + SecurityDetections: {}, + SecurityDetectionNew: { search: Search.SecurityDetectionNewSearch }, SecurityRun: { runId: SecurityAuditRunId }, SecurityFinding: { targetId: SecurityAuditTargetId, fingerprint: Schema.String }, Dashboard: { dashboardId: DashboardId, search: Search.DashboardSearch }, diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index 3a107722cb..d89e72d3d6 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -181,6 +181,8 @@ const isSecurityRoute = AppRoute.isAnyOf([ "SecurityRuntimes", "SecurityDependencies", "SecurityAuditLog", + "SecurityDetections", + "SecurityDetectionNew", "SecurityRun", "SecurityFinding", ]) diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 391dc7a7ca..29285aa99d 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -351,6 +351,15 @@ describe("routes", () => { _tag: "SecurityAuditLog", search: { category: "credential", outcome: "denied" }, }) + expect(parse("/security/detections")._tag).toBe("SecurityDetections") + const prefilled = parse("/security/detections/new?provider=vercel&action=team.member.role.updated") + expect(prefilled).toMatchObject({ + _tag: "SecurityDetectionNew", + search: { provider: "vercel", action: "team.member.role.updated" }, + }) + expect(hrefOf(prefilled)).toBe( + "/security/detections/new?provider=vercel&action=team.member.role.updated", + ) expect(parse("/security/code/runs/run_9f3c2a1e7b")).toMatchObject({ _tag: "SecurityRun", runId: "run_9f3c2a1e7b", diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 71410a7b5d..106e0dfb5c 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -214,6 +214,12 @@ const routers = { search(Search.SecurityAuditLogSearch), Route.mapTo(AppRoute.SecurityAuditLog), ), + SecurityDetections: pipe(path("security", "detections"), Route.mapTo(AppRoute.SecurityDetections)), + SecurityDetectionNew: pipe( + path("security", "detections", "new"), + search(Search.SecurityDetectionNewSearch), + Route.mapTo(AppRoute.SecurityDetectionNew), + ), SecurityRun: pipe( path("security", "code", "runs"), slash(schemaSegment("runId", SecurityAuditRunId)), diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 4600fec5d0..f76421d32d 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -1096,6 +1096,21 @@ export const SecurityAuditLogSearch = Schema.Struct({ }) export type SecurityAuditLogSearch = typeof SecurityAuditLogSearch.Type +/** + * `/security/detections/new`: `detectionId` edits that detection. The rest prefill a new one: a + * ready-made `template`, or the audit log's filters. The form drops a value it has no name for. + */ +export const SecurityDetectionNewSearch = Schema.Struct({ + detectionId: StringParam, + template: StringParam, + provider: StringParam, + category: StringParam, + outcome: StringParam, + /** The platform's own action name, matched exactly. */ + action: StringParam, +}) +export type SecurityDetectionNewSearch = typeof SecurityDetectionNewSearch.Type + export type SecurityFindingsSearch = typeof SecurityFindingsSearch.Type export type SecurityRunsSearch = typeof SecurityRunsSearch.Type export type SecurityDependenciesSearch = typeof SecurityDependenciesSearch.Type diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 8eb0b690d1..62808294bf 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -95,6 +95,8 @@ const SAMPLE_PATHS = [ "/security/runtimes", "/security/dependencies", "/security/audit-log", + "/security/detections", + "/security/detections/new", "/security/code/runs/run_1", "/security/code/findings/00000000-0000-4000-8000-000000000005/fp_1", "/agent-sessions",