mirror of
https://github.com/rustfs/console.git
synced 2026-10-31 03:52:11 +08:00
feat: add audit target management
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiRefreshLine, RiDeleteBin5Line } from "@remixicon/react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { SearchInput } from "@/components/search-input"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useAuditTarget } from "@/hooks/use-audit-target"
|
||||
import { useModuleSwitches } from "@/hooks/use-module-switches"
|
||||
import { AuditTargetNewForm } from "@/components/audit-target/new-form"
|
||||
import { canManageAuditTargets } from "@/lib/audit-target-access"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
interface RowData {
|
||||
account_id: string
|
||||
service: string
|
||||
status: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function isConfigSource(source: string | undefined) {
|
||||
return source === "config"
|
||||
}
|
||||
|
||||
export default function AuditTargetPage() {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { getAuditTargetList, deleteAuditTarget } = useAuditTarget()
|
||||
const { getModuleSwitches } = useModuleSwitches()
|
||||
|
||||
const [data, setData] = useState<RowData[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [newFormOpen, setNewFormOpen] = useState(false)
|
||||
const [auditEnabled, setAuditEnabled] = useState<boolean | undefined>(undefined)
|
||||
|
||||
const canManageTargets = canManageAuditTargets(auditEnabled)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [response, switches] = await Promise.allSettled([
|
||||
getAuditTargetList(),
|
||||
getModuleSwitches({ suppress403Redirect: true }),
|
||||
])
|
||||
|
||||
if (switches.status === "fulfilled" && switches.value) {
|
||||
setAuditEnabled(switches.value.audit_enabled)
|
||||
}
|
||||
|
||||
if (response.status === "rejected") {
|
||||
throw response.reason
|
||||
}
|
||||
|
||||
const list = (response.value?.audit_endpoints ?? []) as RowData[]
|
||||
setData(list)
|
||||
} catch {
|
||||
setData([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [getAuditTargetList, getModuleSwitches])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm) return data
|
||||
const term = searchTerm.toLowerCase()
|
||||
return data.filter(
|
||||
(row) =>
|
||||
row.account_id?.toLowerCase().includes(term) ||
|
||||
row.service?.toLowerCase().includes(term) ||
|
||||
row.source?.toLowerCase().includes(term),
|
||||
)
|
||||
}, [data, searchTerm])
|
||||
|
||||
const deleteItem = useCallback(
|
||||
async (row: RowData) => {
|
||||
if (!canManageTargets) {
|
||||
message.warning(t("Audit is disabled. Enable audit before managing audit targets."))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAuditTarget(`audit_${row.service}`, row.account_id)
|
||||
message.success(t("Delete Success"))
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const msg = (error as Error)?.message || t("Delete Failed")
|
||||
message.error(msg)
|
||||
}
|
||||
},
|
||||
[canManageTargets, deleteAuditTarget, loadData, message, t],
|
||||
)
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
(row: RowData) => {
|
||||
if (!canManageTargets) return
|
||||
if (!isConfigSource(row.source)) return
|
||||
|
||||
dialog.error({
|
||||
title: t("Warning"),
|
||||
content: t("Are you sure you want to delete this audit target?"),
|
||||
positiveText: t("Confirm"),
|
||||
negativeText: t("Cancel"),
|
||||
onPositiveClick: () => deleteItem(row),
|
||||
})
|
||||
},
|
||||
[canManageTargets, deleteItem, dialog, t],
|
||||
)
|
||||
|
||||
const columns: ColumnDef<RowData>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "account_id",
|
||||
header: () => t("Audit Targets"),
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.account_id}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "service",
|
||||
header: () => t("Type"),
|
||||
cell: ({ row }) => <span>{row.original.service}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: () => t("Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.status === "online" ? "secondary" : "outline"}>
|
||||
{row.original.status === "online" ? t("Online") : row.original.status || "-"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "source",
|
||||
header: () => t("Source"),
|
||||
cell: ({ row }) => {
|
||||
const source = row.original.source
|
||||
|
||||
if (!source) return <span>-</span>
|
||||
|
||||
return <Badge variant={isConfigSource(source) ? "secondary" : "outline"}>{source}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("Actions"),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { width: 90 },
|
||||
cell: ({ row }) =>
|
||||
isConfigSource(row.original.source) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(row.original)}
|
||||
disabled={!canManageTargets}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canManageTargets, confirmDelete, t],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<RowData>({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId: (row) => `${row.service}-${row.account_id}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("Search")}
|
||||
clearable
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)} disabled={!canManageTargets}>
|
||||
<RiAddLine className="size-4" aria-hidden />
|
||||
<span>{t("Add Audit Target")}</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" aria-hidden />
|
||||
<span>{t("Refresh")}</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("Audit Targets")}</h1>
|
||||
</PageHeader>
|
||||
|
||||
{!canManageTargets ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("Audit is disabled")}</AlertTitle>
|
||||
<AlertDescription>{t("Enable audit in Settings before managing audit targets.")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyTitle={t("No Audit Targets")}
|
||||
emptyDescription={t("Create an audit target to forward audit logs.")}
|
||||
/>
|
||||
|
||||
<AuditTargetNewForm open={newFormOpen} onOpenChange={setNewFormOpen} onSuccess={loadData} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { useAuditTarget } from "@/hooks/use-audit-target"
|
||||
import {
|
||||
AUDIT_TARGET_CONFIG_OPTIONS,
|
||||
AUDIT_TARGET_TYPE_MAPPING,
|
||||
AUDIT_TARGET_TYPE_OPTIONS,
|
||||
type AuditTargetType,
|
||||
} from "@/lib/audit-target-config"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buildRoute } from "@/lib/routes"
|
||||
|
||||
interface AuditTargetNewFormProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function AuditTargetNewForm({ open, onOpenChange, onSuccess }: AuditTargetNewFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { updateAuditTarget } = useAuditTarget()
|
||||
|
||||
const [type, setType] = React.useState<AuditTargetType | "">("")
|
||||
const [name, setName] = React.useState("")
|
||||
const [config, setConfig] = React.useState<Record<string, string | number>>({})
|
||||
const [nameError, setNameError] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
|
||||
const currentConfigOptions = type ? (AUDIT_TARGET_CONFIG_OPTIONS[type] ?? []) : []
|
||||
const selectedOption = AUDIT_TARGET_TYPE_OPTIONS.find((o) => o.value === type)
|
||||
|
||||
const resetForm = React.useCallback(() => {
|
||||
setType("")
|
||||
setName("")
|
||||
setConfig({})
|
||||
setNameError("")
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
}
|
||||
}, [open, resetForm])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (type && AUDIT_TARGET_CONFIG_OPTIONS[type]) {
|
||||
const nextConfig: Record<string, string | number> = {}
|
||||
AUDIT_TARGET_CONFIG_OPTIONS[type].forEach((item) => {
|
||||
nextConfig[item.name] = ""
|
||||
})
|
||||
setConfig(nextConfig)
|
||||
}
|
||||
}, [type])
|
||||
|
||||
const validateNameFormat = (v: string) => {
|
||||
if (!v) {
|
||||
setNameError(t("Please enter name"))
|
||||
return
|
||||
}
|
||||
if (!/^[A-Za-z0-9_]+$/.test(v)) {
|
||||
setNameError(t("Please enter name") + " (A-Z,0-9,_)")
|
||||
} else {
|
||||
setNameError("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value
|
||||
setName(v)
|
||||
validateNameFormat(v)
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
validateNameFormat(name)
|
||||
if (!type) {
|
||||
message.error(t("Please select audit target type"))
|
||||
return false
|
||||
}
|
||||
if (nameError) {
|
||||
message.error(nameError)
|
||||
return false
|
||||
}
|
||||
const hasConfig = Object.values(config).some((v) => v !== "" && v !== null && v !== undefined)
|
||||
if (!hasConfig) {
|
||||
message.error(t("Please fill in at least one configuration item"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (!type) return
|
||||
const targetType = AUDIT_TARGET_TYPE_MAPPING[type]
|
||||
const keyValues = Object.entries(config).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value ?? ""),
|
||||
}))
|
||||
await updateAuditTarget(targetType, name, { key_values: keyValues })
|
||||
message.success(t("Audit target created successfully"))
|
||||
onSuccess?.()
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const msg = (error as Error)?.message || t("Failed to create audit target")
|
||||
message.error(msg)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{type ? t("Add {type} Audit Target", { type }) : t("Add Audit Target")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
{!type ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{AUDIT_TARGET_TYPE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setType(option.value)}
|
||||
className={cn("cursor-pointer border border-border/70 text-left transition hover:border-primary")}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<Image
|
||||
src={buildRoute(option.icon)}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 shrink-0 object-contain"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-base font-semibold">{t(option.labelKey)}</p>
|
||||
<p className="text-sm text-muted-foreground">{t(option.descKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType("")}
|
||||
className="flex w-full cursor-pointer items-center gap-3 border p-4 text-left transition hover:border-primary"
|
||||
>
|
||||
{selectedOption && (
|
||||
<Image
|
||||
src={buildRoute(selectedOption.icon)}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">{t("Selected Type")}</span>
|
||||
<span className="text-base font-semibold">{type}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="audit-target-name">{t("Name")} (A-Z,0-9,_)</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id="audit-target-name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
placeholder={t("Please enter name")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldContent>
|
||||
{nameError && <FieldDescription className="text-destructive">{nameError}</FieldDescription>}
|
||||
</Field>
|
||||
|
||||
{currentConfigOptions.map((cfg) => (
|
||||
<Field key={cfg.name}>
|
||||
<FieldLabel htmlFor={`audit-config-${cfg.name}`}>{t(cfg.label)}</FieldLabel>
|
||||
<FieldContent>
|
||||
<Input
|
||||
id={`audit-config-${cfg.name}`}
|
||||
value={String(config[cfg.name] ?? "")}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
[cfg.name]: cfg.type === "number" ? Number(e.target.value) || 0 : e.target.value,
|
||||
}))
|
||||
}
|
||||
type={cfg.type === "password" ? "password" : "text"}
|
||||
autoComplete="off"
|
||||
placeholder={`${t("Please enter")} ${t(cfg.label).toLowerCase()}`}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!type || submitting}>
|
||||
{submitting ? t("Saving...") : t("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -80,6 +80,11 @@ export default [
|
||||
to: "/events-target",
|
||||
icon: "ri:bookmark-3-line",
|
||||
},
|
||||
{
|
||||
label: "Audit Targets",
|
||||
to: "/audit-target",
|
||||
icon: "ri:file-shield-2-line",
|
||||
},
|
||||
{
|
||||
label: "SSE Settings",
|
||||
to: "/sse",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import { useApi } from "@/contexts/api-context"
|
||||
|
||||
export function useAuditTarget() {
|
||||
const api = useApi()
|
||||
|
||||
const getAuditTargetList = useCallback(async () => {
|
||||
return api.get("/audit/target/list") as Promise<{
|
||||
audit_endpoints?: Array<{
|
||||
account_id: string
|
||||
service: string
|
||||
status: string
|
||||
source?: string
|
||||
}>
|
||||
}>
|
||||
}, [api])
|
||||
|
||||
const updateAuditTarget = useCallback(
|
||||
async (targetType: string, targetName: string, targetData: Record<string, unknown>) => {
|
||||
return api.put(`/audit/target/${targetType}/${targetName}`, targetData)
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
const deleteAuditTarget = useCallback(
|
||||
async (targetType: string, targetName: string) => {
|
||||
return api.delete(`/audit/target/${targetType}/${targetName}/reset`)
|
||||
},
|
||||
[api],
|
||||
)
|
||||
|
||||
return {
|
||||
getAuditTargetList,
|
||||
updateAuditTarget,
|
||||
deleteAuditTarget,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function canManageAuditTargets(auditEnabled) {
|
||||
return auditEnabled !== false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function canManageAuditTargets(auditEnabled: boolean | undefined) {
|
||||
return auditEnabled !== false
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export const AUDIT_TARGET_CONFIG_OPTIONS = {
|
||||
Kafka: [
|
||||
{ label: "KAFKA_BROKERS", name: "brokers", type: "text" },
|
||||
{ label: "KAFKA_TOPIC", name: "topic", type: "text" },
|
||||
{ label: "KAFKA_ACKS", name: "acks", type: "text" },
|
||||
{ label: "KAFKA_TLS_ENABLE", name: "tls_enable", type: "text" },
|
||||
{ label: "KAFKA_TLS_CA", name: "tls_ca", type: "text" },
|
||||
{ label: "KAFKA_TLS_CLIENT_CERT", name: "tls_client_cert", type: "text" },
|
||||
{ label: "KAFKA_TLS_CLIENT_KEY", name: "tls_client_key", type: "text" },
|
||||
{ label: "KAFKA_QUEUE_DIR", name: "queue_dir", type: "text" },
|
||||
{ label: "KAFKA_QUEUE_LIMIT", name: "queue_limit", type: "number" },
|
||||
{ label: "COMMENT_KEY", name: "comment", type: "text" },
|
||||
],
|
||||
MQTT: [
|
||||
{ label: "MQTT_BROKER", name: "broker", type: "text" },
|
||||
{ label: "MQTT_TOPIC", name: "topic", type: "text" },
|
||||
{ label: "MQTT_QOS", name: "qos", type: "number" },
|
||||
{ label: "MQTT_USERNAME", name: "username", type: "text" },
|
||||
{ label: "MQTT_PASSWORD", name: "password", type: "password" },
|
||||
{ label: "MQTT_RECONNECT_INTERVAL", name: "reconnect_interval", type: "number" },
|
||||
{ label: "MQTT_KEEP_ALIVE_INTERVAL", name: "keep_alive_interval", type: "number" },
|
||||
{ label: "MQTT_QUEUE_DIR", name: "queue_dir", type: "text" },
|
||||
{ label: "MQTT_QUEUE_LIMIT", name: "queue_limit", type: "number" },
|
||||
{ label: "MQTT_TLS_POLICY", name: "tls_policy", type: "text" },
|
||||
{ label: "MQTT_TLS_CA", name: "tls_ca", type: "text" },
|
||||
{ label: "MQTT_TLS_CLIENT_CERT", name: "tls_client_cert", type: "text" },
|
||||
{ label: "MQTT_TLS_CLIENT_KEY", name: "tls_client_key", type: "text" },
|
||||
{ label: "MQTT_TLS_TRUST_LEAF_AS_CA", name: "tls_trust_leaf_as_ca", type: "text" },
|
||||
{ label: "MQTT_WS_PATH_ALLOWLIST", name: "ws_path_allowlist", type: "text" },
|
||||
{ label: "COMMENT_KEY", name: "comment", type: "text" },
|
||||
],
|
||||
Webhook: [
|
||||
{ label: "WEBHOOK_ENDPOINT", name: "endpoint", type: "text" },
|
||||
{ label: "WEBHOOK_AUTH_TOKEN", name: "auth_token", type: "text" },
|
||||
{ label: "WEBHOOK_QUEUE_LIMIT", name: "queue_limit", type: "number" },
|
||||
{ label: "WEBHOOK_QUEUE_DIR", name: "queue_dir", type: "text" },
|
||||
{ label: "WEBHOOK_CLIENT_CERT", name: "client_cert", type: "text" },
|
||||
{ label: "WEBHOOK_CLIENT_KEY", name: "client_key", type: "text" },
|
||||
{ label: "WEBHOOK_CLIENT_CA", name: "client_ca", type: "text" },
|
||||
{ label: "WEBHOOK_SKIP_TLS_VERIFY", name: "skip_tls_verify", type: "text" },
|
||||
{ label: "COMMENT_KEY", name: "comment", type: "text" },
|
||||
],
|
||||
NATS: [
|
||||
{ label: "NATS_ADDRESS", name: "address", type: "text" },
|
||||
{ label: "NATS_SUBJECT", name: "subject", type: "text" },
|
||||
{ label: "NATS_USERNAME", name: "username", type: "text" },
|
||||
{ label: "NATS_PASSWORD", name: "password", type: "password" },
|
||||
{ label: "NATS_TOKEN", name: "token", type: "text" },
|
||||
{ label: "NATS_CREDENTIALS_FILE", name: "credentials_file", type: "text" },
|
||||
{ label: "NATS_TLS_CA", name: "tls_ca", type: "text" },
|
||||
{ label: "NATS_TLS_CLIENT_CERT", name: "tls_client_cert", type: "text" },
|
||||
{ label: "NATS_TLS_CLIENT_KEY", name: "tls_client_key", type: "text" },
|
||||
{ label: "NATS_TLS_REQUIRED", name: "tls_required", type: "text" },
|
||||
{ label: "NATS_QUEUE_DIR", name: "queue_dir", type: "text" },
|
||||
{ label: "NATS_QUEUE_LIMIT", name: "queue_limit", type: "number" },
|
||||
{ label: "COMMENT_KEY", name: "comment", type: "text" },
|
||||
],
|
||||
Pulsar: [
|
||||
{ label: "PULSAR_BROKER", name: "broker", type: "text" },
|
||||
{ label: "PULSAR_TOPIC", name: "topic", type: "text" },
|
||||
{ label: "PULSAR_AUTH_TOKEN", name: "auth_token", type: "text" },
|
||||
{ label: "PULSAR_USERNAME", name: "username", type: "text" },
|
||||
{ label: "PULSAR_PASSWORD", name: "password", type: "password" },
|
||||
{ label: "PULSAR_TLS_CA", name: "tls_ca", type: "text" },
|
||||
{ label: "PULSAR_TLS_ALLOW_INSECURE", name: "tls_allow_insecure", type: "text" },
|
||||
{ label: "PULSAR_TLS_HOSTNAME_VERIFICATION", name: "tls_hostname_verification", type: "text" },
|
||||
{ label: "PULSAR_QUEUE_DIR", name: "queue_dir", type: "text" },
|
||||
{ label: "PULSAR_QUEUE_LIMIT", name: "queue_limit", type: "number" },
|
||||
{ label: "COMMENT_KEY", name: "comment", type: "text" },
|
||||
],
|
||||
}
|
||||
|
||||
export const AUDIT_TARGET_TYPE_OPTIONS = [
|
||||
{
|
||||
labelKey: "Kafka",
|
||||
value: "Kafka",
|
||||
icon: "/svg/kafka.svg",
|
||||
descKey: "Send audit logs via Kafka",
|
||||
},
|
||||
{
|
||||
labelKey: "MQTT",
|
||||
value: "MQTT",
|
||||
icon: "/svg/mqtt.svg",
|
||||
descKey: "Send audit logs via MQTT broker",
|
||||
},
|
||||
{
|
||||
labelKey: "Webhook",
|
||||
value: "Webhook",
|
||||
icon: "/svg/webhooks.svg",
|
||||
descKey: "Send audit logs to HTTP endpoints",
|
||||
},
|
||||
{
|
||||
labelKey: "NATS",
|
||||
value: "NATS",
|
||||
icon: "/svg/nats.svg",
|
||||
descKey: "Send audit logs via NATS",
|
||||
},
|
||||
{
|
||||
labelKey: "Pulsar",
|
||||
value: "Pulsar",
|
||||
icon: "/svg/pulsar.svg",
|
||||
descKey: "Send audit logs via Pulsar",
|
||||
},
|
||||
]
|
||||
|
||||
export const AUDIT_TARGET_TYPE_MAPPING = {
|
||||
Kafka: "audit_kafka",
|
||||
MQTT: "audit_mqtt",
|
||||
Webhook: "audit_webhook",
|
||||
NATS: "audit_nats",
|
||||
Pulsar: "audit_pulsar",
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
AUDIT_TARGET_CONFIG_OPTIONS as JS_AUDIT_TARGET_CONFIG_OPTIONS,
|
||||
AUDIT_TARGET_TYPE_MAPPING as JS_AUDIT_TARGET_TYPE_MAPPING,
|
||||
AUDIT_TARGET_TYPE_OPTIONS as JS_AUDIT_TARGET_TYPE_OPTIONS,
|
||||
} from "./audit-target-config.js"
|
||||
|
||||
export type AuditTargetType = "Kafka" | "MQTT" | "Webhook" | "NATS" | "Pulsar"
|
||||
|
||||
export type AuditTargetConfigField = {
|
||||
label: string
|
||||
name: string
|
||||
type: "text" | "password" | "number"
|
||||
}
|
||||
|
||||
export const AUDIT_TARGET_CONFIG_OPTIONS = JS_AUDIT_TARGET_CONFIG_OPTIONS as Record<
|
||||
AuditTargetType,
|
||||
AuditTargetConfigField[]
|
||||
>
|
||||
|
||||
export const AUDIT_TARGET_TYPE_OPTIONS = JS_AUDIT_TARGET_TYPE_OPTIONS as Array<{
|
||||
labelKey: string
|
||||
value: AuditTargetType
|
||||
icon: string
|
||||
descKey: string
|
||||
}>
|
||||
|
||||
export const AUDIT_TARGET_TYPE_MAPPING = JS_AUDIT_TARGET_TYPE_MAPPING as Record<AuditTargetType, string>
|
||||
@@ -36,6 +36,7 @@ export const PAGE_PERMISSIONS: Record<string, ConsoleScope[]> = {
|
||||
"/lifecycle": [CONSOLE_SCOPES.VIEW_BUCKET_LIFECYCLE],
|
||||
"/tiers": [CONSOLE_SCOPES.VIEW_TIERED_STORAGE],
|
||||
"/events-target": [CONSOLE_SCOPES.VIEW_EVENT_DESTINATIONS],
|
||||
"/audit-target": [CONSOLE_SCOPES.VIEW_EVENT_DESTINATIONS],
|
||||
"/sse": [CONSOLE_SCOPES.VIEW_SSE_SETTINGS],
|
||||
"/oidc": [CONSOLE_SCOPES.VIEW_OIDC_SETTINGS],
|
||||
"/settings": [CONSOLE_SCOPES.VIEW_SETTINGS],
|
||||
|
||||
@@ -19,6 +19,7 @@ export const MENU_CONTROLLED_DASHBOARD_ROUTES: readonly string[] = [
|
||||
"/status",
|
||||
"/tiers",
|
||||
"/events-target",
|
||||
"/audit-target",
|
||||
"/sse",
|
||||
"/oidc",
|
||||
"/settings",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RiBarChartBoxLine,
|
||||
RiStackLine,
|
||||
RiBookmark3Line,
|
||||
RiFileShield2Line,
|
||||
RiSecurePaymentLine,
|
||||
RiSettingsLine,
|
||||
RiFingerprintLine,
|
||||
@@ -34,6 +35,7 @@ const iconMap: Record<string, ComponentType<{ className?: string }>> = {
|
||||
"ri:bar-chart-box-line": RiBarChartBoxLine,
|
||||
"ri:stack-line": RiStackLine,
|
||||
"ri:bookmark-3-line": RiBookmark3Line,
|
||||
"ri:file-shield-2-line": RiFileShield2Line,
|
||||
"ri:secure-payment-line": RiSecurePaymentLine,
|
||||
"ri:settings-line": RiSettingsLine,
|
||||
"ri:fingerprint-line": RiFingerprintLine,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { canManageAuditTargets } from "../../lib/audit-target-access.js"
|
||||
|
||||
test("canManageAuditTargets blocks changes only when audit is disabled", () => {
|
||||
assert.equal(canManageAuditTargets(false), false)
|
||||
assert.equal(canManageAuditTargets(true), true)
|
||||
assert.equal(canManageAuditTargets(undefined), true)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
|
||||
import {
|
||||
AUDIT_TARGET_CONFIG_OPTIONS,
|
||||
AUDIT_TARGET_TYPE_MAPPING,
|
||||
AUDIT_TARGET_TYPE_OPTIONS,
|
||||
} from "../../lib/audit-target-config.js"
|
||||
|
||||
test("audit target config exposes fields matching backend audit contracts", () => {
|
||||
assert.deepEqual(
|
||||
AUDIT_TARGET_CONFIG_OPTIONS.Kafka.map((field) => field.name),
|
||||
[
|
||||
"brokers",
|
||||
"topic",
|
||||
"acks",
|
||||
"tls_enable",
|
||||
"tls_ca",
|
||||
"tls_client_cert",
|
||||
"tls_client_key",
|
||||
"queue_dir",
|
||||
"queue_limit",
|
||||
"comment",
|
||||
],
|
||||
)
|
||||
assert.deepEqual(
|
||||
AUDIT_TARGET_CONFIG_OPTIONS.Webhook.map((field) => field.name),
|
||||
[
|
||||
"endpoint",
|
||||
"auth_token",
|
||||
"queue_limit",
|
||||
"queue_dir",
|
||||
"client_cert",
|
||||
"client_key",
|
||||
"client_ca",
|
||||
"skip_tls_verify",
|
||||
"comment",
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test("audit target type metadata maps console types to audit subsystems", () => {
|
||||
const kafkaOption = AUDIT_TARGET_TYPE_OPTIONS.find((option) => option.value === "Kafka")
|
||||
|
||||
assert.ok(kafkaOption)
|
||||
assert.equal(kafkaOption.icon, "/svg/kafka.svg")
|
||||
assert.equal(AUDIT_TARGET_TYPE_MAPPING.Kafka, "audit_kafka")
|
||||
assert.equal(AUDIT_TARGET_TYPE_MAPPING.MQTT, "audit_mqtt")
|
||||
assert.equal(AUDIT_TARGET_TYPE_MAPPING.Webhook, "audit_webhook")
|
||||
assert.equal(AUDIT_TARGET_TYPE_MAPPING.NATS, "audit_nats")
|
||||
assert.equal(AUDIT_TARGET_TYPE_MAPPING.Pulsar, "audit_pulsar")
|
||||
})
|
||||
Reference in New Issue
Block a user