From 0c49f074a5a9cdd3ce7360ba3392a0c674ef67a8 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:05:53 +0100 Subject: [PATCH 01/10] Use WIP audit log Omicron API --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 232 +++++++++++++++++++++++--- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 38 +++++ app/api/__generated__/validate.ts | 155 ++++++++++++++++- 5 files changed, 398 insertions(+), 31 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index f18fa05d6..af4b8a89c 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b +ef64ac31dc2c564a5bd40b1fb78cff269c2f9d1a diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index ba3609b9b..1cc95a7c1 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -55,7 +55,7 @@ export type Address = { export type AddressConfig = { /** The set of addresses assigned to the port configuration. */ addresses: Address[] - /** Link to assign the address to */ + /** Link to assign the addresses to. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name } @@ -613,6 +613,18 @@ export type AntiAffinityGroupResultsPage = { */ export type AntiAffinityGroupUpdate = { description?: string | null; name?: Name | null } +/** + * An identifier for an artifact. + */ +export type ArtifactId = { + /** The kind of artifact this is. */ + kind: string + /** The artifact's name. */ + name: string + /** The artifact's version. */ + version: string +} + /** * Authorization scope for a timeseries. * @@ -837,7 +849,7 @@ export type BgpPeer = { /** How long to hold a peer in idle before attempting a new session (seconds). */ idleHoldTime: number /** The name of interface to peer on. This is relative to the port configuration this BGP peer configuration is a part of. For example this value could be phy0 to refer to a primary physical interface. Or it could be vlan47 to refer to a VLAN interface. */ - interfaceName: string + interfaceName: Name /** How often to send keepalive requests (seconds). */ keepalive: number /** Apply a local preference to routes received from this peer. */ @@ -855,7 +867,7 @@ export type BgpPeer = { } export type BgpPeerConfig = { - /** Link that the peer is reachable on */ + /** Link that the peer is reachable on. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name peers: BgpPeer[] } @@ -1646,6 +1658,7 @@ export type DeviceAccessToken = { /** A unique, immutable, system-controlled identifier for the token. Note that this ID is not the bearer token itself, which starts with "oxide-token-" */ id: string timeCreated: Date + /** Expiration timestamp. A null value means the token does not automatically expire. */ timeExpires?: Date | null } @@ -2259,7 +2272,7 @@ Currently, the global default auto-restart policy is "best-effort", so instances This disk can either be attached if it already exists or created along with the instance. -Specifying a boot disk is optional but recommended to ensure predictable boot behavior. The boot disk can be set during instance creation or later if the instance is stopped. +Specifying a boot disk is optional but recommended to ensure predictable boot behavior. The boot disk can be set during instance creation or later if the instance is stopped. The boot disk counts against the disk attachment limit. An instance that does not have a boot disk set will use the boot options specified in its UEFI settings, which are controlled by both the instance's UEFI firmware and the guest operating system. Boot options can change as disks are attached and detached, which may result in an instance that only boots to the EFI shell until a boot disk is set. */ bootDisk?: InstanceDiskAttachment | null @@ -2268,7 +2281,7 @@ An instance that does not have a boot disk set will use the boot options specifi Disk attachments of type "create" will be created, while those of type "attach" must already exist. -The order of this list does not guarantee a boot order for the instance. Use the boot_disk attribute to specify a boot disk. */ +The order of this list does not guarantee a boot order for the instance. Use the boot_disk attribute to specify a boot disk. When boot_disk is specified it will count against the disk attachment limit. */ disks?: InstanceDiskAttachment[] /** The external IP addresses provided to this instance. @@ -2726,11 +2739,11 @@ export type TxEqConfig = { * Switch link configuration. */ export type LinkConfigCreate = { - /** Whether or not to set autonegotiation */ + /** Whether or not to set autonegotiation. */ autoneg: boolean /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ fec?: LinkFec | null - /** Link name */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** The link-layer discovery protocol (LLDP) configuration for the link. */ lldp: LldpLinkConfigCreate @@ -2738,7 +2751,7 @@ export type LinkConfigCreate = { mtu: number /** The speed of the link. */ speed: LinkSpeed - /** Optional tx_eq settings */ + /** Optional tx_eq settings. */ txEq?: TxEqConfig | null } @@ -3213,7 +3226,7 @@ export type Route = { * Route configuration data associated with a switch port configuration. */ export type RouteConfig = { - /** Link the route should be active on */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** The set of routes assigned to a switch port. */ routes: Route[] @@ -3644,7 +3657,7 @@ An expunged sled is always non-provisionable. */ | { kind: 'expunged' } /** - * The current state of the sled, as determined by Nexus. + * The current state of the sled. */ export type SledState = /** The sled is currently active, and has resources allocated on it. */ @@ -3666,7 +3679,7 @@ export type Sled = { policy: SledPolicy /** The rack to which this Sled is currently attached */ rackId: string - /** The current state Nexus believes the sled to be in. */ + /** The current state of the sled. */ state: SledState /** timestamp when this resource was created */ timeCreated: Date @@ -3899,7 +3912,7 @@ export type SwitchInterfaceConfig = { /** A unique identifier for this switch interface. */ id: string /** The name of this switch interface. */ - interfaceName: string + interfaceName: Name /** The switch interface kind. */ kind: SwitchInterfaceKind2 /** The port settings object this switch interface configuration belongs to. */ @@ -3929,7 +3942,7 @@ export type SwitchInterfaceKind = export type SwitchInterfaceConfigCreate = { /** What kind of switch interface this configuration represents. */ kind: SwitchInterfaceKind - /** Link the interface will be assigned to */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** Whether or not IPv6 is enabled. */ v6Enabled: boolean @@ -3944,7 +3957,7 @@ export type SwitchPort = { /** The id of the switch port. */ id: string /** The name of this switch port. */ - portName: string + portName: Name /** The primary settings group of this switch port. Will be `None` until this switch port is configured. */ portSettingsId?: string | null /** The rack this switch port belongs to. */ @@ -3966,7 +3979,7 @@ export type SwitchPortAddressView = { /** The name of the address lot this address is drawn from. */ addressLotName: Name /** The interface name this address belongs to. */ - interfaceName: string + interfaceName: Name /** The port settings object this address configuration belongs to. */ portSettingsId: string /** An optional VLAN ID */ @@ -4050,7 +4063,7 @@ export type SwitchPortLinkConfig = { /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ fec?: LinkFec | null /** The name of this link. */ - linkName: string + linkName: Name /** The link-layer discovery protocol service configuration for this link. */ lldpLinkConfig?: LldpLinkConfig | null /** The maximum transmission unit for this link. */ @@ -4082,7 +4095,7 @@ export type SwitchPortRouteConfig = { /** The route's gateway address. */ gw: string /** The interface name this route configuration is assigned to. */ - interfaceName: string + interfaceName: Name /** The port settings object this route configuration belongs to. */ portSettingsId: string /** RIB Priority indicating priority within and across protocols. */ @@ -4147,19 +4160,19 @@ export type SwitchPortSettings = { * Parameters for creating switch port settings. Switch port settings are the central data structure for setting up external networking. Switch port settings include link, interface, route, address and dynamic network protocol configuration. */ export type SwitchPortSettingsCreate = { - /** Addresses indexed by interface name. */ + /** Address configurations. */ addresses: AddressConfig[] - /** BGP peers indexed by interface name. */ + /** BGP peer configurations. */ bgpPeers?: BgpPeerConfig[] description: string groups?: NameOrId[] - /** Interfaces indexed by link name. */ + /** Interface configurations. */ interfaces?: SwitchInterfaceConfigCreate[] - /** Links indexed by phy name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ + /** Link configurations. */ links: LinkConfigCreate[] name: Name portConfig: SwitchPortConfigCreate - /** Routes indexed by interface name. */ + /** Route configurations. */ routes?: RouteConfig[] } @@ -4285,6 +4298,82 @@ export type TimeseriesSchemaResultsPage = { nextPage?: string | null } +/** + * Metadata about an individual TUF artifact. + * + * Found within a `TufRepoDescription`. + */ +export type TufArtifactMeta = { + /** The hash of the artifact. */ + hash: string + /** The artifact ID. */ + id: ArtifactId + /** The size of the artifact in bytes. */ + size: number +} + +/** + * Metadata about a TUF repository. + * + * Found within a `TufRepoDescription`. + */ +export type TufRepoMeta = { + /** The file name of the repository. + +This is purely used for debugging and may not always be correct (e.g. with wicket, we read the file contents from stdin so we don't know the correct file name). */ + fileName: string + /** The hash of the repository. + +This is a slight abuse of `ArtifactHash`, since that's the hash of individual artifacts within the repository. However, we use it here for convenience. */ + hash: string + /** The system version in artifacts.json. */ + systemVersion: string + /** The version of the targets role. */ + targetsRoleVersion: number + /** The time until which the repo is valid. */ + validUntil: Date +} + +/** + * A description of an uploaded TUF repository. + */ +export type TufRepoDescription = { + /** Information about the artifacts present in the repository. */ + artifacts: TufArtifactMeta[] + /** Information about the repository. */ + repo: TufRepoMeta +} + +/** + * Data about a successful TUF repo get from Nexus. + */ +export type TufRepoGetResponse = { + /** The description of the repository. */ + description: TufRepoDescription +} + +/** + * Status of a TUF repo import. + * + * Part of `TufRepoInsertResponse`. + */ +export type TufRepoInsertStatus = + /** The repository already existed in the database. */ + | 'already_exists' + + /** The repository did not exist, and was inserted into the database. */ + | 'inserted' + +/** + * Data about a successful TUF repo import into Nexus. + */ +export type TufRepoInsertResponse = { + /** The repository as present in the database. */ + recorded: TufRepoDescription + /** Whether this repository already existed or is new. */ + status: TufRepoInsertStatus +} + /** * A sled that has not been added to an initialized rack yet */ @@ -4776,6 +4865,48 @@ export type SystemMetricName = | 'cpus_provisioned' | 'ram_provisioned' +/** + * Audit log entry + */ +export type AuditLogEntry = { + /** API token or session cookie. Optional because it will not be defined on unauthenticated requests like login attempts. */ + accessMethod?: string | null + /** User ID of the actor who performed the action */ + actorId?: string | null + actorSiloId?: string | null + /** Error information if the action failed */ + errorCode?: string | null + errorMessage?: string | null + /** HTTP status code */ + httpStatusCode: number + /** Unique identifier for the audit log entry */ + id: string + /** API endpoint ID, e.g., `project_create` */ + operationId: string + /** Request ID for tracing requests through the system */ + requestId: string + /** Full URL of the request */ + requestUri: string + /** Resource identifier */ + resourceId?: string | null + /** IP address that made the request */ + sourceIp: string + /** Time operation completed */ + timeCompleted: Date + /** When the request was received */ + timestamp: Date +} + +/** + * A single page of results + */ +export type AuditLogEntryResultsPage = { + /** list of items on this page of results */ + items: AuditLogEntry[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + /** * Supported set of sort modes for scanning by name only * @@ -6036,6 +6167,14 @@ export interface SystemTimeseriesSchemaListQueryParams { pageToken?: string | null } +export interface SystemUpdatePutRepositoryQueryParams { + fileName: string +} + +export interface SystemUpdateGetRepositoryPathParams { + systemVersion: string +} + export interface SiloUserListQueryParams { limit?: number | null pageToken?: string | null @@ -6280,6 +6419,14 @@ export interface WebhookSecretsDeletePathParams { secretId: string } +export interface AuditLogListQueryParams { + endTime?: Date | null + limit?: number | null + pageToken?: string | null + sortBy?: TimeAndIdSortMode + startTime?: Date +} + type EmptyObj = Record export class Api extends HttpClient { methods = { @@ -9650,6 +9797,33 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Upload TUF repository + */ + systemUpdatePutRepository: ( + { query }: { query: SystemUpdatePutRepositoryQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/repository`, + method: 'PUT', + query, + ...params, + }) + }, + /** + * Fetch TUF repository description + */ + systemUpdateGetRepository: ( + { path }: { path: SystemUpdateGetRepositoryPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/repository/${path.systemVersion}`, + method: 'GET', + ...params, + }) + }, /** * Get the current target release of the rack's system software */ @@ -10251,6 +10425,20 @@ export class Api extends HttpClient { ...params, }) }, + /** + * View audit log + */ + auditLogList: ( + { query = {} }: { query?: AuditLogListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/audit-log`, + method: 'GET', + query, + ...params, + }) + }, } ws = { /** diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 907cbd5f8..8f5724904 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b +ef64ac31dc2c564a5bd40b1fb78cff269c2f9d1a diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 096491ef6..7e6841314 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -1481,6 +1481,18 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `PUT /v1/system/update/repository` */ + systemUpdatePutRepository: (params: { + query: Api.SystemUpdatePutRepositoryQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/system/update/repository/:systemVersion` */ + systemUpdateGetRepository: (params: { + path: Api.SystemUpdateGetRepositoryPathParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/update/target-release` */ targetReleaseView: (params: { req: Request @@ -1739,6 +1751,12 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/system/audit-log` */ + auditLogList: (params: { + query: Api.AuditLogListQueryParams + req: Request + cookies: Record + }) => Promisable> } function validateParams( @@ -3034,6 +3052,22 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), + http.put( + '/v1/system/update/repository', + handler( + handlers['systemUpdatePutRepository'], + schema.SystemUpdatePutRepositoryParams, + null + ) + ), + http.get( + '/v1/system/update/repository/:systemVersion', + handler( + handlers['systemUpdateGetRepository'], + schema.SystemUpdateGetRepositoryParams, + null + ) + ), http.get( '/v1/system/update/target-release', handler(handlers['targetReleaseView'], null, null) @@ -3222,5 +3256,9 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/webhook-secrets/:secretId', handler(handlers['webhookSecretsDelete'], schema.WebhookSecretsDeleteParams, null) ), + http.get( + '/v1/system/audit-log', + handler(handlers['auditLogList'], schema.AuditLogListParams, null) + ), ] } diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 3c54af9f8..9b7ba859b 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -602,6 +602,14 @@ export const AntiAffinityGroupUpdate = z.preprocess( }) ) +/** + * An identifier for an artifact. + */ +export const ArtifactId = z.preprocess( + processResponseBody, + z.object({ kind: z.string(), name: z.string(), version: z.string() }) +) + /** * Authorization scope for a timeseries. * @@ -805,7 +813,7 @@ export const BgpPeer = z.preprocess( enforceFirstAs: SafeBoolean, holdTime: z.number().min(0).max(4294967295), idleHoldTime: z.number().min(0).max(4294967295), - interfaceName: z.string(), + interfaceName: Name, keepalive: z.number().min(0).max(4294967295), localPref: z.number().min(0).max(4294967295).nullable().optional(), md5AuthKey: z.string().nullable().optional(), @@ -3421,7 +3429,7 @@ export const SledPolicy = z.preprocess( ) /** - * The current state of the sled, as determined by Nexus. + * The current state of the sled. */ export const SledState = z.preprocess( processResponseBody, @@ -3632,7 +3640,7 @@ export const SwitchInterfaceConfig = z.preprocess( processResponseBody, z.object({ id: z.string().uuid(), - interfaceName: z.string(), + interfaceName: Name, kind: SwitchInterfaceKind2, portSettingsId: z.string().uuid(), v6Enabled: SafeBoolean, @@ -3668,7 +3676,7 @@ export const SwitchPort = z.preprocess( processResponseBody, z.object({ id: z.string().uuid(), - portName: z.string(), + portName: Name, portSettingsId: z.string().uuid().nullable().optional(), rackId: z.string().uuid(), switchLocation: z.string(), @@ -3685,7 +3693,7 @@ export const SwitchPortAddressView = z.preprocess( addressLotBlockId: z.string().uuid(), addressLotId: z.string().uuid(), addressLotName: Name, - interfaceName: z.string(), + interfaceName: Name, portSettingsId: z.string().uuid(), vlanId: z.number().min(0).max(65535).nullable().optional(), }) @@ -3753,7 +3761,7 @@ export const SwitchPortLinkConfig = z.preprocess( z.object({ autoneg: SafeBoolean, fec: LinkFec.nullable().optional(), - linkName: z.string(), + linkName: Name, lldpLinkConfig: LldpLinkConfig.nullable().optional(), mtu: z.number().min(0).max(65535), portSettingsId: z.string().uuid(), @@ -3778,7 +3786,7 @@ export const SwitchPortRouteConfig = z.preprocess( z.object({ dst: IpNet, gw: z.string().ip(), - interfaceName: z.string(), + interfaceName: Name, portSettingsId: z.string().uuid(), ribPriority: z.number().min(0).max(255).nullable().optional(), vlanId: z.number().min(0).max(65535).nullable().optional(), @@ -3980,6 +3988,70 @@ export const TimeseriesSchemaResultsPage = z.preprocess( z.object({ items: TimeseriesSchema.array(), nextPage: z.string().nullable().optional() }) ) +/** + * Metadata about an individual TUF artifact. + * + * Found within a `TufRepoDescription`. + */ +export const TufArtifactMeta = z.preprocess( + processResponseBody, + z.object({ hash: z.string(), id: ArtifactId, size: z.number().min(0) }) +) + +/** + * Metadata about a TUF repository. + * + * Found within a `TufRepoDescription`. + */ +export const TufRepoMeta = z.preprocess( + processResponseBody, + z.object({ + fileName: z.string(), + hash: z.string(), + systemVersion: z + .string() + .regex( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ + ), + targetsRoleVersion: z.number().min(0), + validUntil: z.coerce.date(), + }) +) + +/** + * A description of an uploaded TUF repository. + */ +export const TufRepoDescription = z.preprocess( + processResponseBody, + z.object({ artifacts: TufArtifactMeta.array(), repo: TufRepoMeta }) +) + +/** + * Data about a successful TUF repo get from Nexus. + */ +export const TufRepoGetResponse = z.preprocess( + processResponseBody, + z.object({ description: TufRepoDescription }) +) + +/** + * Status of a TUF repo import. + * + * Part of `TufRepoInsertResponse`. + */ +export const TufRepoInsertStatus = z.preprocess( + processResponseBody, + z.enum(['already_exists', 'inserted']) +) + +/** + * Data about a successful TUF repo import into Nexus. + */ +export const TufRepoInsertResponse = z.preprocess( + processResponseBody, + z.object({ recorded: TufRepoDescription, status: TufRepoInsertStatus }) +) + /** * A sled that has not been added to an initialized rack yet */ @@ -4461,6 +4533,37 @@ export const SystemMetricName = z.preprocess( z.enum(['virtual_disk_space_provisioned', 'cpus_provisioned', 'ram_provisioned']) ) +/** + * Audit log entry + */ +export const AuditLogEntry = z.preprocess( + processResponseBody, + z.object({ + accessMethod: z.string().nullable().optional(), + actorId: z.string().uuid().nullable().optional(), + actorSiloId: z.string().uuid().nullable().optional(), + errorCode: z.string().nullable().optional(), + errorMessage: z.string().nullable().optional(), + httpStatusCode: z.number().min(0).max(65535), + id: z.string().uuid(), + operationId: z.string(), + requestId: z.string(), + requestUri: z.string(), + resourceId: z.string().uuid().nullable().optional(), + sourceIp: z.string().ip(), + timeCompleted: z.coerce.date(), + timestamp: z.coerce.date(), + }) +) + +/** + * A single page of results + */ +export const AuditLogEntryResultsPage = z.preprocess( + processResponseBody, + z.object({ items: AuditLogEntry.array(), nextPage: z.string().nullable().optional() }) +) + /** * Supported set of sort modes for scanning by name only * @@ -6967,6 +7070,30 @@ export const SystemTimeseriesSchemaListParams = z.preprocess( }) ) +export const SystemUpdatePutRepositoryParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + fileName: z.string(), + }), + }) +) + +export const SystemUpdateGetRepositoryParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + systemVersion: z + .string() + .regex( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ + ), + }), + query: z.object({}), + }) +) + export const TargetReleaseViewParams = z.preprocess( processResponseBody, z.object({ @@ -7424,3 +7551,17 @@ export const WebhookSecretsDeleteParams = z.preprocess( query: z.object({}), }) ) + +export const AuditLogListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + endTime: z.coerce.date().nullable().optional(), + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: TimeAndIdSortMode.optional(), + startTime: z.coerce.date().optional(), + }), + }) +) From 91ea5ec38c2ed76579386b36ca064d8d991643d9 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:06:15 +0100 Subject: [PATCH 02/10] Stub out virtualised audit log page --- app/layouts/SiloLayout.tsx | 5 + app/layouts/helpers.tsx | 2 +- app/pages/SiloAuditLogsPage.tsx | 337 ++++++++++++++++++++++++++++++++ app/routes.tsx | 4 + app/util/date.ts | 16 ++ app/util/path-builder.ts | 1 + mock-api/audit-log.ts | 189 ++++++++++++++++++ mock-api/index.ts | 1 + mock-api/msw/db.ts | 1 + mock-api/msw/handlers.ts | 14 ++ 10 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 app/pages/SiloAuditLogsPage.tsx create mode 100644 mock-api/audit-log.ts diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 361727119..d97687ff4 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -12,6 +12,7 @@ import { Access16Icon, Folder16Icon, Images16Icon, + Logs16Icon, Metrics16Icon, } from '@oxide/design-system/icons/react' @@ -37,6 +38,7 @@ export default function SiloLayout() { { value: 'Images', path: pb.siloImages() }, { value: 'Utilization', path: pb.siloUtilization() }, { value: 'Silo Access', path: pb.siloAccess() }, + { value: 'Audit Logs', path: pb.siloAuditLogs() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -70,6 +72,9 @@ export default function SiloLayout() { Silo Access + + Audit Logs + diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx index ae08a2e69..3c36bb390 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -28,7 +28,7 @@ export function ContentPane() { >
-
+
diff --git a/app/pages/SiloAuditLogsPage.tsx b/app/pages/SiloAuditLogsPage.tsx new file mode 100644 index 000000000..d9fdbe1df --- /dev/null +++ b/app/pages/SiloAuditLogsPage.tsx @@ -0,0 +1,337 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { getLocalTimeZone, now } from '@internationalized/date' +import { useInfiniteQuery, useIsFetching } from '@tanstack/react-query' +import { useVirtualizer } from '@tanstack/react-virtual' +import cn from 'classnames' +import { differenceInMilliseconds } from 'date-fns' +import { memo, useCallback, useMemo, useRef, useState } from 'react' + +import { api } from '@oxide/api' +import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' +import { useIntervalPicker } from '~/components/RefetchIntervalPicker' +import { Badge } from '~/ui/lib/Badge' +import { Button } from '~/ui/lib/Button' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { Spinner } from '~/ui/lib/Spinner' +import { toSyslogDateString, toSyslogTimeString } from '~/util/date' +import { docLinks } from '~/util/links' + +// silly faux highlighting +// avoids unnecessary import of a library and all that overhead +const HighlightJSON = memo(({ jsonString }: { jsonString: string }) => { + const Indent = ({ depth }: { depth: number }) => ( + + ) + + const Primitive = ({ value }: { value: null | boolean | number | string }) => ( + + {value === null ? 'null' : typeof value === 'string' ? `"${value}"` : String(value)} + + ) + + const renderValue = ( + value: null | boolean | number | string | object, + depth = 0 + ): React.ReactNode => { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return + } + + if (Array.isArray(value)) { + if (value.length === 0) return [] + + return ( + <> + [ + {'\n'} + {value.map((item, index) => ( + + + {renderValue(item, depth + 1)} + {index < value.length - 1 && ,} + {'\n'} + + ))} + + ] + + ) + } + + if (typeof value === 'object') { + const entries = Object.entries(value) + if (entries.length === 0) return {'{}'} + + return ( + <> + {'{'} + {'\n'} + {entries.map(([key, val], index) => ( + + + {key} + : + {renderValue(val, depth + 1)} + {index < entries.length - 1 && ,} + {'\n'} + + ))} + + {'}'} + + ) + } + + return String(value) + } + + try { + const parsed = JSON.parse(jsonString) + return <>{renderValue(parsed)} + } catch { + return <>{jsonString} + } +}) + +export const handle = { crumb: 'Audit Logs' } + +export default function SiloAuditLogsPage() { + const [expandedItem, setExpandedItem] = useState(null) + + // pass refetch interval to this to keep the date up to date + const { preset, startTime, endTime, dateTimeRangePicker, onRangeChange } = + useDateTimeRangePicker({ + initialPreset: 'lastHour', + maxValue: now(getLocalTimeZone()), + }) + + const { intervalPicker } = useIntervalPicker({ + enabled: preset !== 'custom', + isLoading: useIsFetching({ queryKey: ['auditLogList'] }) > 0, + // sliding the range forward is sufficient to trigger a refetch + fn: () => onRangeChange(preset), + }) + + const queryParams = { + startTime, + endTime, + limit: 500, + } + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isPending, + isFetching, + error, + } = useInfiniteQuery({ + queryKey: ['auditLogList', { query: queryParams }], + queryFn: ({ pageParam }) => + api.methods + .auditLogList({ query: { ...queryParams, pageToken: pageParam } }) + .then((result) => { + if (result.type === 'success') return result.data + throw result + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextPage || undefined, + placeholderData: (x) => x, + }) + + const auditLogs = useMemo(() => { + return data?.pages.flatMap((page) => page.items) || [] + }, [data]) + + const parentRef = useRef(null) + + const EXPANDED_HEIGHT = 282 + + const rowVirtualizer = useVirtualizer({ + count: auditLogs.length, + getScrollElement: () => document.querySelector('#scroll-container'), + estimateSize: useCallback( + (index) => { + return expandedItem === index.toString() ? 36 + EXPANDED_HEIGHT : 36 + }, + [expandedItem, EXPANDED_HEIGHT] + ), + overscan: 20, + }) + + const handleToggle = useCallback( + (index: string | null) => { + setExpandedItem(index) + rowVirtualizer.measure() + }, + [rowVirtualizer] + ) + + const LogTable = () => ( + <> +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const log = auditLogs[virtualRow.index] + const isExpanded = expandedItem === virtualRow.index.toString() + const jsonString = JSON.stringify(log, null, 2) + + return ( +
+
+ + {isExpanded && ( +
+
+                      
+                    
+
+ )} +
+
+ ) + })} +
+
+ {!hasNextPage && !isFetching && !isPending && auditLogs.length > 0 ? ( +
+ No more logs to show within selected timeline +
+ ) : ( + + )} +
+ + ) + + // todo + // might want to still render the items in case of error + const ErrorState = () => { + return
Error State
+ } + + // todo + const LoadingState = () => { + return
Loading State
+ } + + return ( + <> + + }>Audit Logs + } + summary="Audit logs provide a record of all system activities, including user actions, API calls, and system events." + links={[docLinks.auditLogs]} + /> + + +
+
{intervalPicker}
+
{dateTimeRangePicker}
+
+ +
+ {['Time', 'Status', 'Operation', 'Actor', 'Access Method', 'Silo', 'Duration'].map( + (header) => ( +
+ {header} +
+ ) + )} +
+ +
+
+ {error ? : !isLoading ? : } +
+
+ + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index f653b18af..0ddb3672f 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -252,6 +252,10 @@ export const routes = createRoutesFromElements( import('./pages/SiloAccessPage').then(convert)} /> + import('./pages/SiloAuditLogsPage').then(convert)} + /> {/* PROJECT */} diff --git a/app/util/date.ts b/app/util/date.ts index 9f504267d..81aa17e16 100644 --- a/app/util/date.ts +++ b/app/util/date.ts @@ -53,3 +53,19 @@ export const toLocaleTimeString = (d: Date, locale?: string) => export const toLocaleDateTimeString = (d: Date, locale?: string) => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d) + +// `Jan 21` +export const toSyslogDateString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + }).format(d) + +// `23:33:45` +export const toSyslogTimeString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(d) diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 1a75b7354..ca612806c 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -107,6 +107,7 @@ export const pb = { siloAccess: () => '/access', siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, + siloAuditLogs: () => '/audit-logs', systemUtilization: () => '/system/utilization', diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts new file mode 100644 index 000000000..46fc0f72b --- /dev/null +++ b/mock-api/audit-log.ts @@ -0,0 +1,189 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { v4 as uuid } from 'uuid' + +import type { AuditLogEntry } from '@oxide/api' + +const mockUserIds = [ + 'a47ac10b-58cc-4372-a567-0e02b2c3d479', + '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + 'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd', + '550e8400-e29b-41d4-a716-446655440000', +] + +const mockSiloIds = [ + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '7ba7b810-9dad-11d1-80b4-00c04fd430c8', +] + +const mockOperations = [ + 'instance_create', + 'instance_delete', + 'instance_start', + 'instance_stop', + 'instance_reboot', + 'project_create', + 'project_delete', + 'project_update', + 'disk_create', + 'disk_delete', + 'disk_attach', + 'disk_detach', + 'image_create', + 'image_delete', + 'image_promote', + 'image_demote', + 'vpc_create', + 'vpc_delete', + 'vpc_update', + 'floating_ip_create', + 'floating_ip_delete', + 'floating_ip_attach', + 'floating_ip_detach', + 'snapshot_create', + 'snapshot_delete', + 'silo_create', + 'silo_delete', + 'user_login', + 'user_logout', + 'ssh_key_create', + 'ssh_key_delete', +] + +const mockAccessMethods = ['session_cookie', 'api_token', null] + +const mockHttpStatusCodes = [200, 201, 204, 400, 401, 403, 404, 409, 500, 502, 503] + +const mockSourceIps = [ + '192.168.1.100', + '10.0.0.50', + '172.16.0.25', + '203.0.113.15', + '198.51.100.42', +] + +const mockRequestIds = Array.from({ length: 20 }, () => uuid()) + +function generateAuditLogEntry(index: number): AuditLogEntry { + const operation = mockOperations[index % mockOperations.length] + const statusCode = mockHttpStatusCodes[index % mockHttpStatusCodes.length] + const isError = statusCode >= 400 + const baseTime = new Date() + baseTime.setSeconds(baseTime.getSeconds() - index * 5 * 1) // Spread entries over time + + const completedTime = new Date(baseTime) + completedTime.setMilliseconds( + Math.abs(Math.sin(index)) * 300 + completedTime.getMilliseconds() + ) // Deterministic random durations + + return { + id: uuid(), + accessMethod: mockAccessMethods[index % mockAccessMethods.length], + actorId: mockUserIds[index % mockUserIds.length], + actorSiloId: mockSiloIds[index % mockSiloIds.length], + errorCode: isError ? `E${statusCode}` : null, + errorMessage: isError ? `Operation failed with status ${statusCode}` : null, + httpStatusCode: statusCode, + operationId: operation, + requestId: mockRequestIds[index % mockRequestIds.length], + timestamp: baseTime, + timeCompleted: completedTime, + requestUri: `/v1/projects/default/${operation.replace('_', '/')}`, + resourceId: index % 3 === 0 ? uuid() : null, + sourceIp: mockSourceIps[index % mockSourceIps.length], + } +} + +export const auditLogs: AuditLogEntry[] = [ + // Recent successful operations + { + id: uuid(), + accessMethod: 'session_cookie', + actorId: mockUserIds[0], + actorSiloId: mockSiloIds[0], + errorCode: null, + errorMessage: null, + httpStatusCode: 201, + operationId: 'instance_create', + requestId: mockRequestIds[0], + timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 5 + 321), // 1 second later + requestUri: '/v1/projects/admin-project/instances', + resourceId: uuid(), + sourceIp: '192.168.1.100', + }, + { + id: uuid(), + accessMethod: 'api_token', + actorId: mockUserIds[1], + actorSiloId: mockSiloIds[0], + errorCode: null, + errorMessage: null, + httpStatusCode: 200, + operationId: 'instance_start', + requestId: mockRequestIds[1], + timestamp: new Date(Date.now() - 1000 * 60 * 10), // 10 minutes ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 10 + 126), // 1 second later + requestUri: '/v1/projects/admin-project/instances/web-server-prod/start', + resourceId: uuid(), + sourceIp: '10.0.0.50', + }, + // Failed operations + { + id: uuid(), + accessMethod: 'session_cookie', + actorId: mockUserIds[2], + actorSiloId: mockSiloIds[1], + errorCode: 'E403', + errorMessage: 'Insufficient permissions to delete instance', + httpStatusCode: 403, + operationId: 'instance_delete', + requestId: mockRequestIds[2], + timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 15 + 147), // 1 second later + requestUri: '/v1/projects/dev-project/instances/test-instance', + resourceId: uuid(), + sourceIp: '172.16.0.25', + }, + { + id: uuid(), + accessMethod: null, + actorId: null, + actorSiloId: null, + errorCode: 'E401', + errorMessage: 'Authentication required', + httpStatusCode: 401, + operationId: 'user_login', + requestId: mockRequestIds[3], + timestamp: new Date(Date.now() - 1000 * 60 * 20), // 20 minutes ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 20 + 16), // 1 second later + requestUri: '/v1/login', + resourceId: null, + sourceIp: '203.0.113.15', + }, + // More historical entries + { + id: uuid(), + accessMethod: 'session_cookie', + actorId: mockUserIds[0], + actorSiloId: mockSiloIds[0], + errorCode: null, + errorMessage: null, + httpStatusCode: 201, + operationId: 'project_create', + requestId: mockRequestIds[4], + timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 60 + 36), // 1 second later + requestUri: '/v1/projects', + resourceId: uuid(), + sourceIp: '192.168.1.100', + }, + // Generate additional entries + ...Array.from({ length: 199995 }, (_, i) => generateAuditLogEntry(i + 5)), +] diff --git a/mock-api/index.ts b/mock-api/index.ts index ed6851294..a2593fb11 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -7,6 +7,7 @@ */ export * from './affinity-group' +export * from './audit-log' export * from './disk' export * from './external-ip' export * from './floating-ip' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index cecc37e66..cfa07af05 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -477,6 +477,7 @@ const initDb = { affinityGroupMemberLists: [...mock.affinityGroupMemberLists], antiAffinityGroups: [...mock.antiAffinityGroups], antiAffinityGroupMemberLists: [...mock.antiAffinityGroupMemberLists], + auditLogs: [...mock.auditLogs], deviceTokens: [...mock.deviceTokens], disks: [...mock.disks], diskBulkImportState: new Map(), diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c868aa41e..32eeeabbf 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1799,7 +1799,19 @@ export const handlers = makeHandlers({ ) return paginated(query, affinityGroups) }, + auditLogList: ({ query }) => { + let filteredLogs = db.auditLogs + if (query.startTime) { + filteredLogs = filteredLogs.filter((log) => log.timestamp >= query.startTime!) + } + + if (query.endTime) { + filteredLogs = filteredLogs.filter((log) => log.timestamp <= query.endTime!) + } + + return paginated(query, filteredLogs) + }, // Misc endpoints we're not using yet in the console affinityGroupCreate: NotImplemented, affinityGroupDelete: NotImplemented, @@ -1900,6 +1912,8 @@ export const handlers = makeHandlers({ systemPolicyUpdate: NotImplemented, systemQuotasList: NotImplemented, systemTimeseriesSchemaList: NotImplemented, + systemUpdateGetRepository: NotImplemented, + systemUpdatePutRepository: NotImplemented, targetReleaseUpdate: NotImplemented, targetReleaseView: NotImplemented, userBuiltinList: NotImplemented, From 5eb3ec0ffbaddf53b51cc41c213e6bc78b915b5e Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:06:22 +0100 Subject: [PATCH 03/10] Denser inputs --- app/ui/lib/DatePicker.tsx | 2 +- app/ui/lib/DateRangePicker.tsx | 2 +- app/ui/lib/Listbox.tsx | 2 +- app/ui/lib/Table.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/ui/lib/DatePicker.tsx b/app/ui/lib/DatePicker.tsx index ae50282ff..e4ab8ac3a 100644 --- a/app/ui/lib/DatePicker.tsx +++ b/app/ui/lib/DatePicker.tsx @@ -55,7 +55,7 @@ export function DatePicker(props: DatePickerProps) { type="button" className={cn( state.isOpen && 'z-10 ring-2', - 'relative flex h-11 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', + 'relative flex h-10 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', state.isInvalid ? 'focus-error border-error ring-error-secondary' : 'border-default ring-accent-secondary' diff --git a/app/ui/lib/DateRangePicker.tsx b/app/ui/lib/DateRangePicker.tsx index ff7e2c71c..0f696e30d 100644 --- a/app/ui/lib/DateRangePicker.tsx +++ b/app/ui/lib/DateRangePicker.tsx @@ -63,7 +63,7 @@ export function DateRangePicker(props: DateRangePickerProps) { type="button" className={cn( state.isOpen && 'z-10 ring-2', - 'relative flex h-11 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', + 'relative flex h-10 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', state.isInvalid ? 'focus-error border-error ring-error-secondary hover:border-error' : 'border-default ring-accent-secondary' diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx index 71aef4a94..b6be2bc60 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -101,7 +101,7 @@ export const Listbox = ({ id={id} name={name} className={cn( - `flex h-11 items-center justify-between rounded border text-sans-md`, + `flex h-10 items-center justify-between rounded border text-sans-md`, hasError ? 'focus-error border-error-secondary hover:border-error' : 'border-default hover:border-hover', diff --git a/app/ui/lib/Table.tsx b/app/ui/lib/Table.tsx index b48d13771..27e3a0465 100644 --- a/app/ui/lib/Table.tsx +++ b/app/ui/lib/Table.tsx @@ -105,7 +105,7 @@ Table.Cell = ({ height = 'small', className, children, ...props }: TableCellProp
{children} From fdb45063ee02e215432aecc8647ba5808c16be47 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:08:05 +0100 Subject: [PATCH 04/10] Re-add missing link --- app/util/links.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/util/links.ts b/app/util/links.ts index 913f9c6f1..29c2245ee 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -12,6 +12,7 @@ export const links = { accessDocs: 'https://docs.oxide.computer/guides/configuring-access', affinityDocs: 'https://docs.oxide.computer/guides/deploying-workloads#_affinity_and_anti_affinity', + auditLogsDocs: 'https://docs.oxide.computer/guides/audit-logs', cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html', cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html', deviceTokenSetup: @@ -75,6 +76,10 @@ export const docLinks = { href: links.affinityDocs, linkText: 'Anti-Affinity Groups', }, + auditLogs: { + href: links.auditLogsDocs, + linkText: 'Audit Logs', + }, deviceTokens: { href: links.deviceTokenSetup, linkText: 'Access Tokens', From 35be627b1f88db6900fca03769ea2fdfb73f6241 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:10:28 +0100 Subject: [PATCH 05/10] Update API --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 174 ++++++++++++++++---------- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 39 ++++-- app/api/__generated__/validate.ts | 142 ++++++++++++++------- 5 files changed, 236 insertions(+), 123 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index af4b8a89c..fee9b1e46 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -ef64ac31dc2c564a5bd40b1fb78cff269c2f9d1a +b610bb2bb8c75aa66aa9d0cec91da9a33b8703fb diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 1cc95a7c1..91762746f 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -625,6 +625,48 @@ export type ArtifactId = { version: string } +/** + * Audit log entry + */ +export type AuditLogEntry = { + /** API token or session cookie. Optional because it will not be defined on unauthenticated requests like login attempts. */ + accessMethod?: string | null + /** User ID of the actor who performed the action */ + actorId?: string | null + actorSiloId?: string | null + /** Error information if the action failed */ + errorCode?: string | null + errorMessage?: string | null + /** HTTP status code */ + httpStatusCode: number + /** Unique identifier for the audit log entry */ + id: string + /** API endpoint ID, e.g., `project_create` */ + operationId: string + /** Request ID for tracing requests through the system */ + requestId: string + /** Full URL of the request */ + requestUri: string + /** Resource identifier */ + resourceId?: string | null + /** IP address that made the request */ + sourceIp: string + /** Time operation completed */ + timeCompleted: Date + /** When the request was received */ + timestamp: Date +} + +/** + * A single page of results + */ +export type AuditLogEntryResultsPage = { + /** list of items on this page of results */ + items: AuditLogEntry[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + /** * Authorization scope for a timeseries. * @@ -2053,6 +2095,13 @@ export type GroupResultsPage = { */ export type Hostname = string +/** + * A range of ICMP(v6) types or codes + * + * An inclusive-inclusive range of ICMP(v6) types or codes. The second value may be omitted to represent a single parameter. + */ +export type IcmpParamRange = string + export type IdentityProviderType = 'saml' /** @@ -3402,6 +3451,14 @@ export type SamlIdentityProviderCreate = { technicalContactEmail: string } +/** + * Configuration of inbound ICMP allowed by API services. + */ +export type ServiceIcmpConfig = { + /** When enabled, Nexus is able to receive ICMP Destination Unreachable type 3 (port unreachable) and type 4 (fragmentation needed), Redirect, and Time Exceeded messages. These enable Nexus to perform Path MTU discovery and better cope with fragmentation issues. Otherwise all inbound ICMP traffic will be dropped. */ + enabled: boolean +} + /** * Parameters for PUT requests to `/v1/system/update/target-release`. */ @@ -4521,6 +4578,8 @@ All IPv6 subnets created from this VPC must be taken from this range, which shou name: Name } +export type VpcFirewallIcmpFilter = { code?: IcmpParamRange | null; icmpType: number } + export type VpcFirewallRuleAction = 'allow' | 'deny' export type VpcFirewallRuleDirection = 'inbound' | 'outbound' @@ -4543,7 +4602,10 @@ export type VpcFirewallRuleHostFilter = /** * The protocols that may be specified in a firewall rule's filter */ -export type VpcFirewallRuleProtocol = 'TCP' | 'UDP' | 'ICMP' +export type VpcFirewallRuleProtocol = + | { type: 'tcp' } + | { type: 'udp' } + | { type: 'icmp'; value: VpcFirewallIcmpFilter | null } /** * Filters reduce the scope of a firewall rule. Without filters, the rule applies to all packets to the targets (or from the targets, if it's an outbound rule). With multiple filters, the rule applies only to packets matching ALL filters. The maximum number of each type of filter is 256. @@ -4865,48 +4927,6 @@ export type SystemMetricName = | 'cpus_provisioned' | 'ram_provisioned' -/** - * Audit log entry - */ -export type AuditLogEntry = { - /** API token or session cookie. Optional because it will not be defined on unauthenticated requests like login attempts. */ - accessMethod?: string | null - /** User ID of the actor who performed the action */ - actorId?: string | null - actorSiloId?: string | null - /** Error information if the action failed */ - errorCode?: string | null - errorMessage?: string | null - /** HTTP status code */ - httpStatusCode: number - /** Unique identifier for the audit log entry */ - id: string - /** API endpoint ID, e.g., `project_create` */ - operationId: string - /** Request ID for tracing requests through the system */ - requestId: string - /** Full URL of the request */ - requestUri: string - /** Resource identifier */ - resourceId?: string | null - /** IP address that made the request */ - sourceIp: string - /** Time operation completed */ - timeCompleted: Date - /** When the request was received */ - timestamp: Date -} - -/** - * A single page of results - */ -export type AuditLogEntryResultsPage = { - /** list of items on this page of results */ - items: AuditLogEntry[] - /** token used to fetch the next page of results (if any) */ - nextPage?: string | null -} - /** * Supported set of sort modes for scanning by name only * @@ -5774,6 +5794,14 @@ export interface SnapshotDeleteQueryParams { project?: NameOrId } +export interface AuditLogListQueryParams { + endTime?: Date | null + limit?: number | null + pageToken?: string | null + sortBy?: TimeAndIdSortMode + startTime?: Date +} + export interface PhysicalDiskListQueryParams { limit?: number | null pageToken?: string | null @@ -6419,14 +6447,6 @@ export interface WebhookSecretsDeletePathParams { secretId: string } -export interface AuditLogListQueryParams { - endTime?: Date | null - limit?: number | null - pageToken?: string | null - sortBy?: TimeAndIdSortMode - startTime?: Date -} - type EmptyObj = Record export class Api extends HttpClient { methods = { @@ -8568,6 +8588,20 @@ export class Api extends HttpClient { ...params, }) }, + /** + * View audit log + */ + auditLogList: ( + { query = {} }: { query?: AuditLogListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/audit-log`, + method: 'GET', + query, + ...params, + }) + }, /** * List physical disks */ @@ -9498,6 +9532,30 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Return whether API services can receive limited ICMP traffic + */ + networkingInboundIcmpView: (_: EmptyObj, params: FetchParams = {}) => { + return this.request({ + path: `/v1/system/networking/inbound-icmp`, + method: 'GET', + ...params, + }) + }, + /** + * Set whether API services can receive limited ICMP traffic + */ + networkingInboundIcmpUpdate: ( + { body }: { body: ServiceIcmpConfig }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/networking/inbound-icmp`, + method: 'PUT', + body, + ...params, + }) + }, /** * List loopback addresses */ @@ -10425,20 +10483,6 @@ export class Api extends HttpClient { ...params, }) }, - /** - * View audit log - */ - auditLogList: ( - { query = {} }: { query?: AuditLogListQueryParams }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/system/audit-log`, - method: 'GET', - query, - ...params, - }) - }, } ws = { /** diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 8f5724904..749d036c2 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -ef64ac31dc2c564a5bd40b1fb78cff269c2f9d1a +b610bb2bb8c75aa66aa9d0cec91da9a33b8703fb diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 7e6841314..8066a72df 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -936,6 +936,12 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/system/audit-log` */ + auditLogList: (params: { + query: Api.AuditLogListQueryParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/hardware/disks` */ physicalDiskList: (params: { query: Api.PhysicalDiskListQueryParams @@ -1341,6 +1347,17 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/networking/inbound-icmp` */ + networkingInboundIcmpView: (params: { + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/system/networking/inbound-icmp` */ + networkingInboundIcmpUpdate: (params: { + body: Json + req: Request + cookies: Record + }) => Promisable /** `GET /v1/system/networking/loopback-address` */ networkingLoopbackAddressList: (params: { query: Api.NetworkingLoopbackAddressListQueryParams @@ -1751,12 +1768,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable - /** `GET /v1/system/audit-log` */ - auditLogList: (params: { - query: Api.AuditLogListQueryParams - req: Request - cookies: Record - }) => Promisable> } function validateParams( @@ -2570,6 +2581,10 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/snapshots/:snapshot', handler(handlers['snapshotDelete'], schema.SnapshotDeleteParams, null) ), + http.get( + '/v1/system/audit-log', + handler(handlers['auditLogList'], schema.AuditLogListParams, null) + ), http.get( '/v1/system/hardware/disks', handler(handlers['physicalDiskList'], schema.PhysicalDiskListParams, null) @@ -2926,6 +2941,14 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/networking/bgp-status', handler(handlers['networkingBgpStatus'], null, null) ), + http.get( + '/v1/system/networking/inbound-icmp', + handler(handlers['networkingInboundIcmpView'], null, null) + ), + http.put( + '/v1/system/networking/inbound-icmp', + handler(handlers['networkingInboundIcmpUpdate'], null, schema.ServiceIcmpConfig) + ), http.get( '/v1/system/networking/loopback-address', handler( @@ -3256,9 +3279,5 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/webhook-secrets/:secretId', handler(handlers['webhookSecretsDelete'], schema.WebhookSecretsDeleteParams, null) ), - http.get( - '/v1/system/audit-log', - handler(handlers['auditLogList'], schema.AuditLogListParams, null) - ), ] } diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 9b7ba859b..3a5ff5d72 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -610,6 +610,37 @@ export const ArtifactId = z.preprocess( z.object({ kind: z.string(), name: z.string(), version: z.string() }) ) +/** + * Audit log entry + */ +export const AuditLogEntry = z.preprocess( + processResponseBody, + z.object({ + accessMethod: z.string().nullable().optional(), + actorId: z.string().uuid().nullable().optional(), + actorSiloId: z.string().uuid().nullable().optional(), + errorCode: z.string().nullable().optional(), + errorMessage: z.string().nullable().optional(), + httpStatusCode: z.number().min(0).max(65535), + id: z.string().uuid(), + operationId: z.string(), + requestId: z.string(), + requestUri: z.string(), + resourceId: z.string().uuid().nullable().optional(), + sourceIp: z.string().ip(), + timeCompleted: z.coerce.date(), + timestamp: z.coerce.date(), + }) +) + +/** + * A single page of results + */ +export const AuditLogEntryResultsPage = z.preprocess( + processResponseBody, + z.object({ items: AuditLogEntry.array(), nextPage: z.string().nullable().optional() }) +) + /** * Authorization scope for a timeseries. * @@ -1952,6 +1983,20 @@ export const Hostname = z.preprocess( .regex(/^([a-zA-Z0-9]+[a-zA-Z0-9\-]*(? Date: Tue, 8 Jul 2025 11:40:59 +0100 Subject: [PATCH 06/10] Mock type fixes --- mock-api/audit-log.ts | 164 ++++++++++++++++++++------------------- mock-api/msw/handlers.ts | 8 +- 2 files changed, 89 insertions(+), 83 deletions(-) diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index 46fc0f72b..394943c95 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -10,6 +10,8 @@ import { v4 as uuid } from 'uuid' import type { AuditLogEntry } from '@oxide/api' +import type { Json } from './json-type' + const mockUserIds = [ 'a47ac10b-58cc-4372-a567-0e02b2c3d479', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', @@ -56,7 +58,7 @@ const mockOperations = [ 'ssh_key_delete', ] -const mockAccessMethods = ['session_cookie', 'api_token', null] +const mockAccessMethod = ['session_cookie', 'api_token', null] const mockHttpStatusCodes = [200, 201, 204, 400, 401, 403, 404, 409, 500, 502, 503] @@ -70,7 +72,7 @@ const mockSourceIps = [ const mockRequestIds = Array.from({ length: 20 }, () => uuid()) -function generateAuditLogEntry(index: number): AuditLogEntry { +function generateAuditLogEntry(index: number): Json { const operation = mockOperations[index % mockOperations.length] const statusCode = mockHttpStatusCodes[index % mockHttpStatusCodes.length] const isError = statusCode >= 400 @@ -84,105 +86,105 @@ function generateAuditLogEntry(index: number): AuditLogEntry { return { id: uuid(), - accessMethod: mockAccessMethods[index % mockAccessMethods.length], - actorId: mockUserIds[index % mockUserIds.length], - actorSiloId: mockSiloIds[index % mockSiloIds.length], - errorCode: isError ? `E${statusCode}` : null, - errorMessage: isError ? `Operation failed with status ${statusCode}` : null, - httpStatusCode: statusCode, - operationId: operation, - requestId: mockRequestIds[index % mockRequestIds.length], - timestamp: baseTime, - timeCompleted: completedTime, - requestUri: `/v1/projects/default/${operation.replace('_', '/')}`, - resourceId: index % 3 === 0 ? uuid() : null, - sourceIp: mockSourceIps[index % mockSourceIps.length], + access_method: mockAccessMethod[index % mockAccessMethod.length], + actor_id: mockUserIds[index % mockUserIds.length], + actor_silo_id: mockSiloIds[index % mockSiloIds.length], + error_code: isError ? `E${statusCode}` : null, + error_message: isError ? `Operation failed with status ${statusCode}` : null, + http_status_code: statusCode, + operation_id: operation, + request_id: mockRequestIds[index % mockRequestIds.length], + timestamp: baseTime.toISOString(), + time_completed: completedTime.toISOString(), + request_uri: `/v1/projects/default/${operation.replace('_', '/')}`, + resource_id: index % 3 === 0 ? uuid() : null, + source_ip: mockSourceIps[index % mockSourceIps.length], } } -export const auditLogs: AuditLogEntry[] = [ +export const auditLogs: Json = [ // Recent successful operations { id: uuid(), - accessMethod: 'session_cookie', - actorId: mockUserIds[0], - actorSiloId: mockSiloIds[0], - errorCode: null, - errorMessage: null, - httpStatusCode: 201, - operationId: 'instance_create', - requestId: mockRequestIds[0], - timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 5 + 321), // 1 second later - requestUri: '/v1/projects/admin-project/instances', - resourceId: uuid(), - sourceIp: '192.168.1.100', + access_method: 'session_cookie', + actor_id: mockUserIds[0], + actor_silo_id: mockSiloIds[0], + error_code: null, + error_message: null, + http_status_code: 201, + operation_id: 'instance_create', + request_id: mockRequestIds[0], + timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(), // 5 minutes ago + time_completed: new Date(Date.now() - 1000 * 60 * 5 + 321).toISOString(), // 1 second later + request_uri: '/v1/projects/admin-project/instances', + resource_id: uuid(), + source_ip: '192.168.1.100', }, { id: uuid(), - accessMethod: 'api_token', - actorId: mockUserIds[1], - actorSiloId: mockSiloIds[0], - errorCode: null, - errorMessage: null, - httpStatusCode: 200, - operationId: 'instance_start', - requestId: mockRequestIds[1], - timestamp: new Date(Date.now() - 1000 * 60 * 10), // 10 minutes ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 10 + 126), // 1 second later - requestUri: '/v1/projects/admin-project/instances/web-server-prod/start', - resourceId: uuid(), - sourceIp: '10.0.0.50', + access_method: 'api_token', + actor_id: mockUserIds[1], + actor_silo_id: mockSiloIds[0], + error_code: null, + error_message: null, + http_status_code: 200, + operation_id: 'instance_start', + request_id: mockRequestIds[1], + timestamp: new Date(Date.now() - 1000 * 60 * 10).toISOString(), // 10 minutes ago + time_completed: new Date(Date.now() - 1000 * 60 * 10 + 126).toISOString(), // 1 second later + request_uri: '/v1/projects/admin-project/instances/web-server-prod/start', + resource_id: uuid(), + source_ip: '10.0.0.50', }, // Failed operations { id: uuid(), - accessMethod: 'session_cookie', - actorId: mockUserIds[2], - actorSiloId: mockSiloIds[1], - errorCode: 'E403', - errorMessage: 'Insufficient permissions to delete instance', - httpStatusCode: 403, - operationId: 'instance_delete', - requestId: mockRequestIds[2], - timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 15 + 147), // 1 second later - requestUri: '/v1/projects/dev-project/instances/test-instance', - resourceId: uuid(), - sourceIp: '172.16.0.25', + access_method: 'session_cookie', + actor_id: mockUserIds[2], + actor_silo_id: mockSiloIds[1], + error_code: 'E403', + error_message: 'Insufficient permissions to delete instance', + http_status_code: 403, + operation_id: 'instance_delete', + request_id: mockRequestIds[2], + timestamp: new Date(Date.now() - 1000 * 60 * 15).toISOString(), // 15 minutes ago + time_completed: new Date(Date.now() - 1000 * 60 * 15 + 147).toISOString(), // 1 second later + request_uri: '/v1/projects/dev-project/instances/test-instance', + resource_id: uuid(), + source_ip: '172.16.0.25', }, { id: uuid(), - accessMethod: null, - actorId: null, - actorSiloId: null, - errorCode: 'E401', - errorMessage: 'Authentication required', - httpStatusCode: 401, - operationId: 'user_login', - requestId: mockRequestIds[3], - timestamp: new Date(Date.now() - 1000 * 60 * 20), // 20 minutes ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 20 + 16), // 1 second later - requestUri: '/v1/login', - resourceId: null, - sourceIp: '203.0.113.15', + access_method: null, + actor_id: null, + actor_silo_id: null, + error_code: 'E401', + error_message: 'Authentication required', + http_status_code: 401, + operation_id: 'user_login', + request_id: mockRequestIds[3], + timestamp: new Date(Date.now() - 1000 * 60 * 20).toISOString(), // 20 minutes ago + time_completed: new Date(Date.now() - 1000 * 60 * 20 + 16).toISOString(), // 1 second later + request_uri: '/v1/login', + resource_id: null, + source_ip: '203.0.113.15', }, // More historical entries { id: uuid(), - accessMethod: 'session_cookie', - actorId: mockUserIds[0], - actorSiloId: mockSiloIds[0], - errorCode: null, - errorMessage: null, - httpStatusCode: 201, - operationId: 'project_create', - requestId: mockRequestIds[4], - timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 60 + 36), // 1 second later - requestUri: '/v1/projects', - resourceId: uuid(), - sourceIp: '192.168.1.100', + access_method: 'session_cookie', + actor_id: mockUserIds[0], + actor_silo_id: mockSiloIds[0], + error_code: null, + error_message: null, + http_status_code: 201, + operation_id: 'project_create', + request_id: mockRequestIds[4], + timestamp: new Date(Date.now() - 1000 * 60 * 60).toISOString(), // 1 hour ago + time_completed: new Date(Date.now() - 1000 * 60 * 60 + 36).toISOString(), // 1 second later + request_uri: '/v1/projects', + resource_id: uuid(), + source_ip: '192.168.1.100', }, // Generate additional entries ...Array.from({ length: 199995 }, (_, i) => generateAuditLogEntry(i + 5)), diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 32eeeabbf..42ab43d8e 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1803,11 +1803,13 @@ export const handlers = makeHandlers({ let filteredLogs = db.auditLogs if (query.startTime) { - filteredLogs = filteredLogs.filter((log) => log.timestamp >= query.startTime!) + filteredLogs = filteredLogs.filter( + (log) => new Date(log.timestamp) >= query.startTime! + ) } if (query.endTime) { - filteredLogs = filteredLogs.filter((log) => log.timestamp <= query.endTime!) + filteredLogs = filteredLogs.filter((log) => new Date(log.timestamp) <= query.endTime!) } return paginated(query, filteredLogs) @@ -1873,6 +1875,8 @@ export const handlers = makeHandlers({ networkingLoopbackAddressCreate: NotImplemented, networkingLoopbackAddressDelete: NotImplemented, networkingLoopbackAddressList: NotImplemented, + networkingInboundIcmpView: NotImplemented, + networkingInboundIcmpUpdate: NotImplemented, networkingSwitchPortApplySettings: NotImplemented, networkingSwitchPortClearSettings: NotImplemented, networkingSwitchPortList: NotImplemented, From e0890d025da54cf91db23f57e0a7d6ead309cd8d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 11 Jul 2025 15:58:32 -0500 Subject: [PATCH 07/10] chore: bump oxlint to 1.6.0 (#2853) --- .oxlintrc.json | 4 +- app/components/MswBanner.tsx | 4 +- package-lock.json | 72 ++++++++++++++++++------------------ package.json | 2 +- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 17ac403c4..f0e3cc7db 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -2,8 +2,10 @@ "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": [ "import", - // defaults "react", + "jsx-a11y", + "promise", + // defaults (see https://oxc.rs/docs/guide/usage/linter/plugins.html#supported-plugins) "unicorn", "typescript", "oxc" diff --git a/app/components/MswBanner.tsx b/app/components/MswBanner.tsx index 58cf8eec1..4e6996aec 100644 --- a/app/components/MswBanner.tsx +++ b/app/components/MswBanner.tsx @@ -49,7 +49,7 @@ export function MswBanner({ disableButton }: Props) { return ( <> {/* The [&+*]:pt-10 style is to ensure the page container isn't pushed out of screen as it uses 100vh for layout */} -