Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee06e31ff | ||
|
|
10225f4613 | ||
|
|
d55026902f | ||
|
|
55d54d06db | ||
|
|
64a1d82db4 | ||
|
|
a5fc8f5490 | ||
|
|
a5d846a437 |
@@ -0,0 +1,6 @@
|
||||
# Copy values into the process environment. Do not commit real credentials.
|
||||
BELL_ENV=development
|
||||
BELL_HTTP_ADDRESS=127.0.0.1:8082
|
||||
BELL_DATABASE_URL=postgres://bell_app:replace-at-deploy@127.0.0.1:5432/bell?sslmode=disable
|
||||
BELL_SESSION_SECRET=generate-an-independent-random-value-at-deploy
|
||||
BELL_SHUTDOWN_SECONDS=10
|
||||
@@ -0,0 +1,11 @@
|
||||
# Upstream sources
|
||||
|
||||
Bell is independently derived from the frozen YoVision GoAdmin baseline.
|
||||
|
||||
| Source | Commit | License | Use |
|
||||
|---|---|---|---|
|
||||
| https://github.com/go-admin-team/go-admin.git | `f06540883b41d03782bb6b2c4150f298f328c6b6` | MIT | Backend architecture reference |
|
||||
| https://github.com/go-admin-team/go-admin-ui.git | `67d393d713877572fab0b897296a4c1d525fc81d` | MIT | Vue 3 application shell and component patterns |
|
||||
|
||||
The local copies under `D:\github\goadmin` are read-only references and are not Bell source directories.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 go-admin-team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 PanJiaChen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Bell
|
||||
|
||||
Bell is YoVision's independently deployable alert intake and response product. It owns its users, roles, sessions, database, audit facts and release artifacts. Sense and Brain are not required for Bell to build or run.
|
||||
|
||||
## Frozen baseline
|
||||
|
||||
- Go `1.26.5`
|
||||
- Node.js `22.22.1`
|
||||
- pnpm `9.15.1`
|
||||
- go-admin commit `f06540883b41d03782bb6b2c4150f298f328c6b6`
|
||||
- go-admin-ui commit `67d393d713877572fab0b897296a4c1d525fc81d`
|
||||
|
||||
Exact upstream sources and retained MIT notices are in `LICENSES/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy variable names from `.env.example` into the process environment. Replace every placeholder at deployment time. Bell intentionally has no built-in database password, session secret or administrator password.
|
||||
|
||||
`BELL_SESSION_SECRET` is mandatory, must be at least 32 characters, and must be generated independently from Sense. In production Bell marks its `bell_session` cookie as Secure, HttpOnly and SameSite=Strict.
|
||||
|
||||
The PostgreSQL role used by `BELL_DATABASE_URL` must be dedicated to Bell. Do not reuse a Sense role or database.
|
||||
|
||||
## Backend
|
||||
|
||||
```powershell
|
||||
Set-Location Bell/server
|
||||
go test ./...
|
||||
go run . migrate
|
||||
go run . serve
|
||||
```
|
||||
|
||||
Create the first administrator only through a one-time process environment value; do not place the password in shell history, source files or command arguments:
|
||||
|
||||
```powershell
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = Read-Host -AsSecureString | ConvertFrom-SecureString -AsPlainText
|
||||
go run . create-admin --username bell-admin --display-name "Bell 管理员"
|
||||
Remove-Item Env:BELL_BOOTSTRAP_PASSWORD
|
||||
```
|
||||
|
||||
Passwords require at least 12 characters with upper-case, lower-case and numeric characters. Bell ships no user or default password. Administrator, operator and viewer roles are seeded with least-privilege permissions; only an administrator can read user and authentication-audit pages.
|
||||
|
||||
Health endpoints:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod http://127.0.0.1:8082/healthz
|
||||
Invoke-RestMethod http://127.0.0.1:8082/readyz
|
||||
```
|
||||
|
||||
Migrations are embedded and applied transactionally. The `migrate` command is idempotent. To roll back this initial skeleton, stop Bell and drop only the dedicated Bell test database; migrations are forward-only and never touch another product database.
|
||||
|
||||
## Web
|
||||
|
||||
```powershell
|
||||
Set-Location Bell/web
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm lint
|
||||
pnpm build:prod
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The web client listens on port 8083 in development and proxies `/api` to Bell only. The visible shell contains only Bell's product navigation; go-admin demonstration routes are neither registered nor served by the backend.
|
||||
|
||||
### Development-only synthetic events
|
||||
|
||||
When `BELL_ENV=development` or `test`, administrators can open “合成事件测试” or call the `/api/v1/synthetic/*` endpoints. Fixtures live under `server/testdata/events/`, contain only invented anonymous data, and call the same Event/Receipt service as every future producer adapter. Reusing a source event ID verifies idempotency; using the same ID with a different fixture verifies conflict handling.
|
||||
|
||||
In `BELL_ENV=production` those routes are not registered and return `404`; hiding the menu is not the security boundary.
|
||||
|
||||
### Rules and alerts
|
||||
|
||||
Administrators configure and enable rules from “规则配置”. Each accepted Event is evaluated in the same database transaction that creates its Event/Receipt. A matching Event creates or joins an open Alert correlated by rule and location; one Event may match several rules and one Alert may collect several related Events. Every evaluation records its rule version and explanation, while every match also stores the actual rule snapshot. Unmatched Events remain visible as Events and are never labelled as notified or handled.
|
||||
|
||||
Alert handling is two-step: the first authorized operator to acknowledge becomes the handler, then that handler (or an administrator) records a required site outcome before closing. PostgreSQL conditional updates select one concurrent acknowledgement winner. Later operators receive the actual handler instead of overwriting it. Successful lifecycle facts are append-only; failed, duplicate and denied attempts are also written to the security audit without tokens or passwords.
|
||||
|
||||
## Independent smoke check
|
||||
|
||||
Run PostgreSQL with an empty Bell-only database, set `BELL_DATABASE_URL`, start the backend and then the web client. Keep Sense and Brain stopped. Verify `/healthz`, `/readyz`, the workbench shell, and a direct request to an unregistered framework demo route returns `404`.
|
||||
|
||||
## Logs and secrets
|
||||
|
||||
Application logs go to standard output. Never log database URLs, passwords, session tokens, event evidence credentials, customer data or production payloads.
|
||||
@@ -0,0 +1,11 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$expectedGo = 'go version go1.26.5 windows/amd64'
|
||||
$expectedNode = 'v22.22.1'
|
||||
$expectedPnpm = '9.15.1'
|
||||
|
||||
if ((go version) -ne $expectedGo) { throw "Expected $expectedGo" }
|
||||
if ((node --version) -ne $expectedNode) { throw "Expected Node $expectedNode" }
|
||||
if ((pnpm --version) -ne $expectedPnpm) { throw "Expected pnpm $expectedPnpm" }
|
||||
|
||||
Write-Output 'Bell toolchain matches goadmin-baseline.json.'
|
||||
@@ -0,0 +1,58 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("POST /api/v1/alerts/{id}/ack", h.Auth.Require(rbac.AlertsHandle, http.HandlerFunc(h.ack)))
|
||||
mux.Handle("POST /api/v1/alerts/{id}/close", h.Auth.Require(rbac.AlertsHandle, http.HandlerFunc(h.close)))
|
||||
}
|
||||
func (h HTTP) ack(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.Service.Ack(r.Context(), r.PathValue("id"), auth.Principal(r.Context()))
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, ErrAlreadyHandled) {
|
||||
status = 409
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": err.Error(), "current": result.Detail})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, result)
|
||||
}
|
||||
func (h HTTP) close(w http.ResponseWriter, r *http.Request) {
|
||||
var input CloseInput
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
result, err := h.Service.Close(r.Context(), r.PathValue("id"), input, auth.Principal(r.Context()))
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, ErrOutcomeRequired) {
|
||||
status = 400
|
||||
} else if errors.Is(err, ErrInvalidTransition) {
|
||||
status = 409
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": err.Error(), "current": result.Detail})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, result)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/model"
|
||||
alertQuery "git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrAlreadyHandled = errors.New("预警已由其他人员开始处理")
|
||||
var ErrInvalidTransition = errors.New("当前状态不能执行此操作")
|
||||
var ErrOutcomeRequired = errors.New("请选择现场处理结果")
|
||||
|
||||
type CloseInput struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
type Result struct {
|
||||
Detail model.Detail `json:"detail"`
|
||||
Idempotent bool `json:"idempotent"`
|
||||
}
|
||||
type Service struct {
|
||||
DB *pgxpool.Pool
|
||||
Audit audit.Store
|
||||
Query alertQuery.Service
|
||||
}
|
||||
|
||||
func hasRole(user auth.User, role string) bool {
|
||||
for _, value := range user.Roles {
|
||||
if value == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func validOutcome(value string) bool {
|
||||
return value == "danger_confirmed" || value == "false_positive" || value == "site_normal" || value == "unable_to_confirm"
|
||||
}
|
||||
|
||||
func (s Service) Ack(ctx context.Context, alertID string, actor auth.User) (Result, error) {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var updated string
|
||||
err = tx.QueryRow(ctx, `UPDATE bell_alerts SET status='acknowledged',acknowledged_by=$2,acknowledged_at=now(),updated_at=now() WHERE id=$1 AND status='open' RETURNING id::text`, alertID, actor.ID).Scan(&updated)
|
||||
if err == nil {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_alert_lifecycle_facts(alert_id,transition,actor_user_id) VALUES($1,'acknowledged',$2)`, alertID, actor.ID); err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,'alert.ack','alert',$2,'success','{}')`, actor.ID, alertID)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return Result{}, err
|
||||
}
|
||||
detail, err := s.Query.Get(ctx, alertID)
|
||||
return Result{Detail: detail}, err
|
||||
}
|
||||
_ = tx.Rollback(ctx)
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Result{}, err
|
||||
}
|
||||
detail, getErr := s.Query.Get(ctx, alertID)
|
||||
if getErr != nil {
|
||||
return Result{}, getErr
|
||||
}
|
||||
if detail.Alert.AcknowledgedBy != nil && *detail.Alert.AcknowledgedBy == actor.ID {
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.ack", "alert", &alertID, "failure", map[string]any{"reason": "duplicate"})
|
||||
return Result{Detail: detail, Idempotent: true}, nil
|
||||
}
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.ack", "alert", &alertID, "failure", map[string]any{"reason": "already_handled"})
|
||||
return Result{Detail: detail}, ErrAlreadyHandled
|
||||
}
|
||||
|
||||
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor auth.User) (Result, error) {
|
||||
if !validOutcome(input.Outcome) {
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "outcome_required"})
|
||||
return Result{}, ErrOutcomeRequired
|
||||
}
|
||||
current, err := s.Query.Get(ctx, alertID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if current.Alert.Status == "closed" {
|
||||
if current.Alert.ClosedBy != nil && *current.Alert.ClosedBy == actor.ID && current.Alert.CloseOutcome != nil && *current.Alert.CloseOutcome == input.Outcome {
|
||||
return Result{Detail: current, Idempotent: true}, nil
|
||||
}
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "already_closed"})
|
||||
return Result{Detail: current}, ErrInvalidTransition
|
||||
}
|
||||
admin := hasRole(actor, "administrator")
|
||||
if current.Alert.Status != "acknowledged" || (!admin && (current.Alert.AcknowledgedBy == nil || *current.Alert.AcknowledgedBy != actor.ID)) {
|
||||
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "invalid_owner_or_state"})
|
||||
return Result{Detail: current}, ErrInvalidTransition
|
||||
}
|
||||
details, _ := json.Marshal(map[string]any{"outcome": input.Outcome, "note": input.Note})
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
result, err := tx.Exec(ctx, `UPDATE bell_alerts SET status='closed',closed_by=$2,closed_at=now(),close_outcome=$3,close_note=nullif($4,''),updated_at=now() WHERE id=$1 AND status='acknowledged'`, alertID, actor.ID, input.Outcome, input.Note)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return Result{}, ErrInvalidTransition
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_alert_lifecycle_facts(alert_id,transition,actor_user_id,details) VALUES($1,'closed',$2,$3::jsonb)`, alertID, actor.ID, string(details)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,'alert.close','alert',$2,'success',$3::jsonb)`, actor.ID, alertID, string(details)); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
detail, err := s.Query.Get(ctx, alertID)
|
||||
return Result{Detail: detail}, err
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rule"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
func TestConcurrentAckCloseAndRestart(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prefix := fmt.Sprintf("lifecycle-%d", time.Now().UnixNano())
|
||||
actors := make([]auth.User, 2)
|
||||
for i := range actors {
|
||||
var id string
|
||||
username := fmt.Sprintf("%s-%d", prefix, i)
|
||||
if err := db.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,'test-only-not-a-login-hash') RETURNING id::text`, username, username).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code='operator'`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actors[i] = auth.User{ID: id, Username: username, DisplayName: username, Roles: []string{"operator"}}
|
||||
}
|
||||
eventType := prefix + "-event"
|
||||
ruleService := rule.Service{DB: db}
|
||||
createdRule, err := ruleService.Create(ctx, rule.CreateInput{Code: prefix, Name: "生命周期测试规则", EventType: &eventType, MinimumSeverity: "low"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventService := event.Service{DB: db, AfterPersist: ruleService.Match}
|
||||
accepted, err := eventService.Ingest(ctx, event.Command{ProducerID: prefix, SourceEventID: "1", EventType: eventType, OccurredAt: time.Now().UTC(), Location: "test place", Severity: "high", Attributes: map[string]any{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queryService := query.Service{DB: db}
|
||||
alerts, err := queryService.ForEvent(ctx, accepted.Event.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alertID := ""
|
||||
for _, item := range alerts {
|
||||
if item.PrimaryRuleID == createdRule.ID {
|
||||
alertID = item.ID
|
||||
}
|
||||
}
|
||||
if alertID == "" {
|
||||
t.Fatal("alert not created")
|
||||
}
|
||||
service := Service{DB: db, Audit: audit.Store{DB: db}, Query: queryService}
|
||||
const attempts = 20
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < attempts; i++ {
|
||||
wg.Add(1)
|
||||
actor := actors[i%2]
|
||||
go func() { defer wg.Done(); _, _ = service.Ack(ctx, alertID, actor) }()
|
||||
}
|
||||
wg.Wait()
|
||||
detail, err := queryService.Get(ctx, alertID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Alert.Status != "acknowledged" || detail.Alert.AcknowledgedBy == nil {
|
||||
t.Fatalf("unexpected ack projection: %#v", detail.Alert)
|
||||
}
|
||||
var facts int
|
||||
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_lifecycle_facts WHERE alert_id=$1 AND transition='acknowledged'`, alertID).Scan(&facts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if facts != 1 {
|
||||
t.Fatalf("ack facts=%d", facts)
|
||||
}
|
||||
winner := actors[0]
|
||||
loser := actors[1]
|
||||
if winner.ID != *detail.Alert.AcknowledgedBy {
|
||||
winner, loser = loser, winner
|
||||
}
|
||||
if _, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal"}, loser); err == nil {
|
||||
t.Fatal("non-owner closed alert")
|
||||
}
|
||||
closed, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal", Note: "现场检查正常"}, winner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if closed.Detail.Alert.Status != "closed" {
|
||||
t.Fatal("alert not closed")
|
||||
}
|
||||
repeat, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal", Note: "现场检查正常"}, winner)
|
||||
if err != nil || !repeat.Idempotent {
|
||||
t.Fatalf("repeat close: %#v %v", repeat, err)
|
||||
}
|
||||
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_lifecycle_facts WHERE alert_id=$1`, alertID).Scan(&facts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if facts != 2 {
|
||||
t.Fatalf("lifecycle facts=%d", facts)
|
||||
}
|
||||
if _, err := db.Exec(ctx, `DELETE FROM bell_alert_lifecycle_facts WHERE alert_id=$1`, alertID); err == nil {
|
||||
t.Fatal("lifecycle deletion succeeded")
|
||||
}
|
||||
db.Close()
|
||||
reopened, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
afterRestart, err := (query.Service{DB: reopened}).Get(ctx, alertID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterRestart.Alert.Status != "closed" || len(afterRestart.Timeline) != 2 {
|
||||
t.Fatalf("restart projection: %#v", afterRestart)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package model
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Location string `json:"location"`
|
||||
PrimaryRuleID string `json:"primary_rule_id"`
|
||||
RuleName string `json:"rule_name"`
|
||||
EventCount int `json:"event_count"`
|
||||
AcknowledgedBy *string `json:"acknowledged_by,omitempty"`
|
||||
AcknowledgedByName *string `json:"acknowledged_by_name,omitempty"`
|
||||
AcknowledgedAt *string `json:"acknowledged_at,omitempty"`
|
||||
ClosedBy *string `json:"closed_by,omitempty"`
|
||||
ClosedByName *string `json:"closed_by_name,omitempty"`
|
||||
ClosedAt *string `json:"closed_at,omitempty"`
|
||||
CloseOutcome *string `json:"close_outcome,omitempty"`
|
||||
CloseNote *string `json:"close_note,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
type LinkedEvent struct {
|
||||
ID string `json:"id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
type Match struct {
|
||||
EventID string `json:"event_id"`
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleVersion int `json:"rule_version"`
|
||||
RuleSnapshot json.RawMessage `json:"rule_snapshot"`
|
||||
Explanation string `json:"explanation"`
|
||||
MatchedAt string `json:"matched_at"`
|
||||
}
|
||||
type Detail struct {
|
||||
Alert Alert `json:"alert"`
|
||||
Events []LinkedEvent `json:"events"`
|
||||
Matches []Match `json:"matches"`
|
||||
Timeline []TimelineEntry `json:"timeline"`
|
||||
}
|
||||
type TimelineEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Transition string `json:"transition"`
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
ActorName string `json:"actor_name"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/alerts", h.Auth.Require(rbac.AlertsRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("GET /api/v1/alerts/{id}", h.Auth.Require(rbac.AlertsRead, http.HandlerFunc(h.get)))
|
||||
mux.Handle("GET /api/v1/events/{id}/alerts", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.forEvent)))
|
||||
}
|
||||
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.Service.List(r.Context(), limit, r.URL.Query().Get("before"))
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取预警失败"})
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > 0 {
|
||||
next = items[len(items)-1].ID
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "next": next})
|
||||
}
|
||||
func (h HTTP) get(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := h.Service.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
status = 404
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": "预警不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, item)
|
||||
}
|
||||
func (h HTTP) forEvent(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Service.ForEvent(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取关联预警失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/model"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Service struct{ DB *pgxpool.Pool }
|
||||
|
||||
const base = `SELECT a.id::text,a.status,a.severity,a.summary,a.location,a.primary_rule_id::text,r.name,(SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id=a.id),a.acknowledged_by::text,ack_user.display_name,a.acknowledged_at::text,a.closed_by::text,close_user.display_name,a.closed_at::text,a.close_outcome,a.close_note,a.created_at::text,a.updated_at::text FROM bell_alerts a JOIN bell_rules r ON r.id=a.primary_rule_id LEFT JOIN bell_users ack_user ON ack_user.id=a.acknowledged_by LEFT JOIN bell_users close_user ON close_user.id=a.closed_by`
|
||||
|
||||
func scanAlert(row interface{ Scan(...any) error }) (model.Alert, error) {
|
||||
var item model.Alert
|
||||
err := row.Scan(&item.ID, &item.Status, &item.Severity, &item.Summary, &item.Location, &item.PrimaryRuleID, &item.RuleName, &item.EventCount, &item.AcknowledgedBy, &item.AcknowledgedByName, &item.AcknowledgedAt, &item.ClosedBy, &item.ClosedByName, &item.ClosedAt, &item.CloseOutcome, &item.CloseNote, &item.CreatedAt, &item.UpdatedAt)
|
||||
return item, err
|
||||
}
|
||||
func (s Service) List(ctx context.Context, limit int, before string) ([]model.Alert, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, base+` WHERE ($2='' OR (a.created_at,a.id)<((SELECT created_at FROM bell_alerts WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY a.created_at DESC,a.id DESC LIMIT $1`, limit, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []model.Alert{}
|
||||
for rows.Next() {
|
||||
item, err := scanAlert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
func (s Service) Get(ctx context.Context, id string) (model.Detail, error) {
|
||||
item, err := scanAlert(s.DB.QueryRow(ctx, base+` WHERE a.id=$1`, id))
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail := model.Detail{Alert: item, Events: []model.LinkedEvent{}, Matches: []model.Match{}, Timeline: []model.TimelineEntry{}}
|
||||
rows, err := s.DB.Query(ctx, `SELECT e.id::text,e.event_type,e.occurred_at::text,e.location,e.severity FROM bell_alert_events ae JOIN bell_events e ON e.id=ae.event_id WHERE ae.alert_id=$1 ORDER BY e.occurred_at,e.id`, id)
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var linked model.LinkedEvent
|
||||
if err := rows.Scan(&linked.ID, &linked.EventType, &linked.OccurredAt, &linked.Location, &linked.Severity); err != nil {
|
||||
rows.Close()
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail.Events = append(detail.Events, linked)
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = s.DB.Query(ctx, `SELECT event_id::text,rule_id::text,rule_version,rule_snapshot,explanation,matched_at::text FROM bell_rule_matches WHERE alert_id=$1 ORDER BY matched_at,event_id`, id)
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var match model.Match
|
||||
if err := rows.Scan(&match.EventID, &match.RuleID, &match.RuleVersion, &match.RuleSnapshot, &match.Explanation, &match.MatchedAt); err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail.Matches = append(detail.Matches, match)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = s.DB.Query(ctx, `SELECT f.id,f.transition,f.actor_user_id::text,u.display_name,f.occurred_at::text,f.details FROM bell_alert_lifecycle_facts f JOIN bell_users u ON u.id=f.actor_user_id WHERE f.alert_id=$1 ORDER BY f.id`, id)
|
||||
if err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var entry model.TimelineEntry
|
||||
if err := rows.Scan(&entry.ID, &entry.Transition, &entry.ActorUserID, &entry.ActorName, &entry.OccurredAt, &entry.Details); err != nil {
|
||||
return model.Detail{}, err
|
||||
}
|
||||
detail.Timeline = append(detail.Timeline, entry)
|
||||
}
|
||||
return detail, rows.Err()
|
||||
}
|
||||
func (s Service) ForEvent(ctx context.Context, eventID string) ([]model.Alert, error) {
|
||||
rows, err := s.DB.Query(ctx, base+` JOIN bell_alert_events link ON link.alert_id=a.id WHERE link.event_id=$1 ORDER BY a.created_at,a.id`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []model.Alert{}
|
||||
for rows.Next() {
|
||||
item, err := scanAlert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
ActorUserID *string `json:"actor_user_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
Outcome string `json:"outcome"`
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
|
||||
type Store struct{ DB *pgxpool.Pool }
|
||||
|
||||
func (s Store) Record(ctx context.Context, actorUserID *string, action, targetType string, targetID *string, outcome string, details map[string]any) error {
|
||||
data, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode audit details: %w", err)
|
||||
}
|
||||
_, err = s.DB.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,$2,$3,$4,$5,$6)`, actorUserID, action, targetType, targetID, outcome, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append audit entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Store) List(ctx context.Context, limit int) ([]Entry, error) {
|
||||
if limit < 1 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT id,occurred_at::text,actor_user_id::text,action,target_type,target_id,outcome,details FROM bell_audit_log ORDER BY id DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
entries := make([]Entry, 0, limit)
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
var data []byte
|
||||
if err := rows.Scan(&e.ID, &e.OccurredAt, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Outcome, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(data, &e.Details)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
const CookieName = "bell_session"
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Store Store
|
||||
Audit audit.Store
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/auth/login", h.login)
|
||||
mux.Handle("GET /api/v1/auth/me", h.authenticated(http.HandlerFunc(h.me)))
|
||||
mux.Handle("POST /api/v1/auth/logout", h.authenticated(http.HandlerFunc(h.logout)))
|
||||
mux.Handle("GET /api/v1/users", h.Require(rbac.UsersRead, http.HandlerFunc(h.users)))
|
||||
mux.Handle("POST /api/v1/users", h.Require(rbac.UsersWrite, http.HandlerFunc(h.createUser)))
|
||||
mux.Handle("PUT /api/v1/users/{id}/roles", h.Require(rbac.UsersWrite, http.HandlerFunc(h.replaceRoles)))
|
||||
mux.Handle("GET /api/v1/audit", h.Require(rbac.AuditRead, http.HandlerFunc(h.auditEntries)))
|
||||
}
|
||||
|
||||
func (h HTTP) login(w http.ResponseWriter, r *http.Request) {
|
||||
var input LoginInput
|
||||
if err := decodeJSON(w, r, &input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result, err := h.Service.Login(r.Context(), input)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.setCookie(w, result.Token, time.Now().Add(8*time.Hour))
|
||||
result.User.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": result.User, "expires_at": result.ExpiresAt})
|
||||
}
|
||||
|
||||
func (h HTTP) me(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, principal(r.Context()))
|
||||
}
|
||||
func (h HTTP) logout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, _ := r.Cookie(CookieName)
|
||||
token := ""
|
||||
if cookie != nil {
|
||||
token = cookie.Value
|
||||
}
|
||||
user := principal(r.Context())
|
||||
if err := h.Service.Logout(r.Context(), token, user); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "退出失败"})
|
||||
return
|
||||
}
|
||||
h.setCookie(w, "", time.Unix(0, 0))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (h HTTP) users(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.Store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取用户失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": users})
|
||||
}
|
||||
func (h HTTP) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
if input.Username == "" || input.DisplayName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "用户名和显示名称必填"})
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(input.Password)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id, err := h.Store.CreateUser(r.Context(), input.Username, input.DisplayName, hash, input.Role)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "用户或角色无效"})
|
||||
return
|
||||
}
|
||||
actor := principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.user_created", "user", &id, "success", map[string]any{"role": input.Role})
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
|
||||
}
|
||||
func (h HTTP) replaceRoles(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &input); err != nil || len(input.Roles) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "至少选择一个角色"})
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.Store.ReplaceRoles(r.Context(), id, input.Roles); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "角色更新失败"})
|
||||
return
|
||||
}
|
||||
actor := principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.roles_replaced", "user", &id, "success", map[string]any{"roles": input.Roles})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (h HTTP) auditEntries(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
entries, err := h.Audit.List(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取审计失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
|
||||
}
|
||||
|
||||
func (h HTTP) authenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.Service.Authenticate(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, user)))
|
||||
})
|
||||
}
|
||||
func (h HTTP) Require(permission string, next http.Handler) http.Handler {
|
||||
return h.authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := principal(r.Context())
|
||||
if !user.Permissions.Has(permission) {
|
||||
_ = h.Audit.Record(r.Context(), &user.ID, "rbac.denied", "permission", &permission, "denied", map[string]any{})
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "没有执行此操作的权限"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
func (h HTTP) setCookie(w http.ResponseWriter, value string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: value, Path: "/", HttpOnly: true, Secure: h.SecureCookie, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
|
||||
}
|
||||
func principal(ctx context.Context) User { user, _ := ctx.Value(principalKey{}).(User); return user }
|
||||
func Principal(ctx context.Context) User { return principal(ctx) }
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(value)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func validatePassword(value string) error {
|
||||
if utf8.RuneCountInString(value) < 12 {
|
||||
return errors.New("密码至少需要 12 个字符")
|
||||
}
|
||||
var lower, upper, digit bool
|
||||
for _, r := range value {
|
||||
lower = lower || unicode.IsLower(r)
|
||||
upper = upper || unicode.IsUpper(r)
|
||||
digit = digit || unicode.IsDigit(r)
|
||||
}
|
||||
if !lower || !upper || !digit {
|
||||
return errors.New("密码必须同时包含大写字母、小写字母和数字")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashPassword(value string) (string, error) {
|
||||
if err := validatePassword(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(value), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func passwordMatches(hash, value string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(value)) == nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPasswordPolicy(t *testing.T) {
|
||||
for _, value := range []string{"short", "alllowercase123", "ALLUPPERCASE123"} {
|
||||
if validatePassword(value) == nil {
|
||||
t.Fatalf("expected %q to fail policy", value)
|
||||
}
|
||||
}
|
||||
if err := validatePassword("Bell-Safe-2026"); err != nil {
|
||||
t.Fatalf("valid password rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHashRoundTrip(t *testing.T) {
|
||||
hash, err := hashPassword("Bell-Safe-2026")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !passwordMatches(hash, "Bell-Safe-2026") {
|
||||
t.Fatal("password did not match")
|
||||
}
|
||||
if passwordMatches(hash, "Wrong-Safe-2026") {
|
||||
t.Fatal("wrong password matched")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
var ErrUnauthorized = errors.New("会话无效或已过期")
|
||||
|
||||
type Service struct {
|
||||
Store Store
|
||||
Audit audit.Store
|
||||
Secret []byte
|
||||
SessionTTL time.Duration
|
||||
}
|
||||
|
||||
func (s Service) digest(token string) []byte {
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
_, _ = mac.Write([]byte(token))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (s Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
|
||||
username := strings.ToLower(strings.TrimSpace(input.Username))
|
||||
user, err := s.Store.UserByUsername(ctx, username)
|
||||
if err != nil || !user.Enabled || !passwordMatches(user.PasswordHash, input.Password) {
|
||||
_ = s.Audit.Record(ctx, nil, "auth.login", "user", nil, "failure", map[string]any{"reason": "invalid_credentials"})
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
expires := time.Now().UTC().Add(s.SessionTTL)
|
||||
if s.SessionTTL == 0 {
|
||||
expires = time.Now().UTC().Add(8 * time.Hour)
|
||||
}
|
||||
if err := s.Store.CreateSession(ctx, user.ID, s.digest(token), expires); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_ = s.Audit.Record(ctx, &user.ID, "auth.login", "user", &user.ID, "success", map[string]any{})
|
||||
return LoginResult{Token: token, User: user, ExpiresAt: expires.Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (s Service) Authenticate(ctx context.Context, token string) (User, error) {
|
||||
if token == "" {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
user, err := s.Store.UserBySessionDigest(ctx, s.digest(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
if !user.Enabled {
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
func (s Service) Logout(ctx context.Context, token string, user User) error {
|
||||
if token != "" {
|
||||
if err := s.Store.RevokeSession(ctx, s.digest(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.Audit.Record(ctx, &user.ID, "auth.logout", "user", &user.ID, "success", map[string]any{})
|
||||
}
|
||||
func (s Service) BootstrapAdministrator(ctx context.Context, username, displayName, password string) error {
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if username == "" {
|
||||
return errors.New("username is required")
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := s.Store.BootstrapAdministrator(ctx, username, displayName, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Audit.Record(ctx, &id, "rbac.bootstrap_administrator", "user", &id, "success", map[string]any{})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct{ DB *pgxpool.Pool }
|
||||
|
||||
func (s Store) UserByUsername(ctx context.Context, username string) (User, error) {
|
||||
var user User
|
||||
err := s.DB.QueryRow(ctx, `SELECT id::text,username,display_name,password_hash,enabled FROM bell_users WHERE username=$1`, username).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err = s.loadAccess(ctx, &user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Store) UserBySessionDigest(ctx context.Context, digest []byte) (User, error) {
|
||||
var user User
|
||||
err := s.DB.QueryRow(ctx, `SELECT u.id::text,u.username,u.display_name,u.password_hash,u.enabled FROM bell_sessions s JOIN bell_users u ON u.id=s.user_id WHERE s.token_digest=$1 AND s.revoked_at IS NULL AND s.expires_at>now()`, digest).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err = s.loadAccess(ctx, &user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Store) loadAccess(ctx context.Context, user *User) error {
|
||||
rows, err := s.DB.Query(ctx, `SELECT DISTINCT r.code,p.permission_code FROM bell_user_roles ur JOIN bell_roles r ON r.id=ur.role_id LEFT JOIN bell_role_permissions p ON p.role_id=r.id WHERE ur.user_id=$1 ORDER BY r.code,p.permission_code`, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
user.Permissions = rbac.Set{}
|
||||
roleSeen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var role string
|
||||
var permission *string
|
||||
if err := rows.Scan(&role, &permission); err != nil {
|
||||
return err
|
||||
}
|
||||
if !roleSeen[role] {
|
||||
user.Roles = append(user.Roles, role)
|
||||
roleSeen[role] = true
|
||||
}
|
||||
if permission != nil {
|
||||
user.Permissions[*permission] = struct{}{}
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) CreateSession(ctx context.Context, userID string, digest []byte, expires time.Time) error {
|
||||
_, err := s.DB.Exec(ctx, `INSERT INTO bell_sessions(user_id,token_digest,expires_at) VALUES($1,$2,$3)`, userID, digest, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Store) RevokeSession(ctx context.Context, digest []byte) error {
|
||||
_, err := s.DB.Exec(ctx, `UPDATE bell_sessions SET revoked_at=COALESCE(revoked_at,now()) WHERE token_digest=$1`, digest)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Store) BootstrapAdministrator(ctx context.Context, username, displayName, passwordHash string) (string, error) {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) ON CONFLICT(username) DO NOTHING RETURNING id::text`, username, displayName, passwordHash).Scan(&id)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return "", fmt.Errorf("administrator %q already exists", username)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code='administrator'`, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,username,display_name,enabled FROM bell_users ORDER BY username LIMIT 200`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []User{}
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range users {
|
||||
if err := s.loadAccess(ctx, &users[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s Store) CreateUser(ctx context.Context, username, displayName, passwordHash, role string) (string, error) {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var id string
|
||||
if err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) RETURNING id::text`, username, displayName, passwordHash).Scan(&id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, id, role)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return "", fmt.Errorf("unknown role %q", role)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s Store) ReplaceRoles(ctx context.Context, userID string, roles []string) error {
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM bell_user_roles WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range roles {
|
||||
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, userID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return fmt.Errorf("unknown role %q", role)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package auth
|
||||
|
||||
import "git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Roles []string `json:"roles"`
|
||||
Permissions rbac.Set `json:"permissions"`
|
||||
PasswordHash string `json:"-"`
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
type LoginResult struct {
|
||||
Token string
|
||||
User User
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
type principalKey struct{}
|
||||
@@ -0,0 +1,52 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/events", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("GET /api/v1/events/{id}", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.get)))
|
||||
}
|
||||
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.Service.List(r.Context(), limit, r.URL.Query().Get("before"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > 0 {
|
||||
next = items[len(items)-1].ID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next": next})
|
||||
}
|
||||
func (h HTTP) get(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := h.Service.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "事件不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrConflict = errors.New("幂等键已用于不同事件载荷")
|
||||
var ErrInvalid = errors.New("事件字段无效")
|
||||
|
||||
type PersistHook func(context.Context, pgx.Tx, Event) error
|
||||
type Service struct {
|
||||
DB *pgxpool.Pool
|
||||
AfterPersist PersistHook
|
||||
}
|
||||
|
||||
func normalize(command Command) ([]byte, [32]byte, error) {
|
||||
command.ProducerID = strings.TrimSpace(command.ProducerID)
|
||||
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
|
||||
command.EventType = strings.TrimSpace(command.EventType)
|
||||
command.Location = strings.TrimSpace(command.Location)
|
||||
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
|
||||
command.OccurredAt = command.OccurredAt.UTC()
|
||||
if command.ProducerID == "" || command.SourceEventID == "" || command.EventType == "" || command.Location == "" || command.OccurredAt.IsZero() {
|
||||
return nil, [32]byte{}, ErrInvalid
|
||||
}
|
||||
if command.Severity != "low" && command.Severity != "medium" && command.Severity != "high" && command.Severity != "critical" {
|
||||
return nil, [32]byte{}, ErrInvalid
|
||||
}
|
||||
if command.EvidenceRef != nil {
|
||||
value := strings.TrimSpace(*command.EvidenceRef)
|
||||
if value == "" || strings.Contains(value, "@") || strings.Contains(value, "\\") || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
return nil, [32]byte{}, fmt.Errorf("%w: evidence_ref must be a safe logical reference", ErrInvalid)
|
||||
}
|
||||
command.EvidenceRef = &value
|
||||
}
|
||||
if command.Attributes == nil {
|
||||
command.Attributes = map[string]any{}
|
||||
}
|
||||
data, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return nil, [32]byte{}, fmt.Errorf("normalize event: %w", err)
|
||||
}
|
||||
return data, sha256.Sum256(data), nil
|
||||
}
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, command Command) (Result, error) {
|
||||
payload, digest, err := normalize(command)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var normalized Command
|
||||
if err := json.Unmarshal(payload, &normalized); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
tx, err := s.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var eventID string
|
||||
err = tx.QueryRow(ctx, `INSERT INTO bell_events(producer_id,source_event_id,event_type,occurred_at,location,severity,evidence_ref,normalized_payload,payload_sha256) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(producer_id,source_event_id) DO NOTHING RETURNING id::text`, normalized.ProducerID, normalized.SourceEventID, normalized.EventType, normalized.OccurredAt, normalized.Location, normalized.Severity, normalized.EvidenceRef, payload, digest[:]).Scan(&eventID)
|
||||
created := err == nil
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Result{}, err
|
||||
}
|
||||
if created {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO bell_event_receipts(event_id,producer_id,source_event_id,payload_sha256) VALUES($1,$2,$3,$4)`, eventID, normalized.ProducerID, normalized.SourceEventID, digest[:]); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
result, storedDigest, err := loadResult(ctx, tx, normalized.ProducerID, normalized.SourceEventID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !created && !equalDigest(storedDigest, digest[:]) {
|
||||
return Result{}, ErrConflict
|
||||
}
|
||||
result.Duplicate = !created
|
||||
if created && s.AfterPersist != nil {
|
||||
if err := s.AfterPersist(ctx, tx, result.Event); err != nil {
|
||||
return Result{}, fmt.Errorf("process accepted Event: %w", err)
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadResult(ctx context.Context, q interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}, producerID, sourceEventID string) (Result, []byte, error) {
|
||||
var result Result
|
||||
var digest []byte
|
||||
err := q.QueryRow(ctx, `SELECT e.id::text,e.producer_id,e.source_event_id,e.event_type,e.occurred_at::text,e.location,e.severity,e.evidence_ref,e.normalized_payload,e.created_at::text,e.payload_sha256,r.id::text,r.accepted_at::text FROM bell_events e JOIN bell_event_receipts r ON r.event_id=e.id WHERE e.producer_id=$1 AND e.source_event_id=$2`, producerID, sourceEventID).Scan(&result.Event.ID, &result.Event.ProducerID, &result.Event.SourceEventID, &result.Event.EventType, &result.Event.OccurredAt, &result.Event.Location, &result.Event.Severity, &result.Event.EvidenceRef, &result.Event.NormalizedPayload, &result.Event.CreatedAt, &digest, &result.Receipt.ID, &result.Receipt.AcceptedAt)
|
||||
result.Receipt.EventID = result.Event.ID
|
||||
result.Receipt.ProducerID = producerID
|
||||
result.Receipt.SourceEventID = sourceEventID
|
||||
return result, digest, err
|
||||
}
|
||||
func equalDigest(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var different byte
|
||||
for i := range a {
|
||||
different |= a[i] ^ b[i]
|
||||
}
|
||||
return different == 0
|
||||
}
|
||||
|
||||
func (s Service) List(ctx context.Context, limit int, before string) ([]Event, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE ($2='' OR (created_at,id)<((SELECT created_at FROM bell_events WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY created_at DESC,id DESC LIMIT $1`, limit, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Event{}
|
||||
for rows.Next() {
|
||||
var item Event
|
||||
if err := rows.Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
func (s Service) Get(ctx context.Context, id string) (Event, error) {
|
||||
var item Event
|
||||
err := s.DB.QueryRow(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE id=$1`, id).Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt)
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestNormalizeRejectsUnsafeEvidence(t *testing.T) {
|
||||
value := `file:C:\camera\secret.mp4`
|
||||
_, _, err := normalize(Command{ProducerID: "test", SourceEventID: "1", EventType: "intrusion", OccurredAt: time.Now(), Location: "gate", Severity: "high", EvidenceRef: &value})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid evidence reference, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresIdempotencyAndImmutability(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := Service{DB: db}
|
||||
key := fmt.Sprintf("event-test-%d", time.Now().UnixNano())
|
||||
command := Command{ProducerID: "integration-test", SourceEventID: key, EventType: "danger-zone", OccurredAt: time.Now().UTC().Truncate(time.Millisecond), Location: "test-zone", Severity: "high", Attributes: map[string]any{"track_id": "anonymous-1"}}
|
||||
const count = 16
|
||||
results := make(chan Result, count)
|
||||
errorsFound := make(chan error, count)
|
||||
var wg sync.WaitGroup
|
||||
for range count {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := service.Ingest(ctx, command)
|
||||
if err != nil {
|
||||
errorsFound <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errorsFound)
|
||||
for err := range errorsFound {
|
||||
t.Errorf("concurrent ingest: %v", err)
|
||||
}
|
||||
var eventID, receiptID string
|
||||
seen := 0
|
||||
for result := range results {
|
||||
seen++
|
||||
if eventID == "" {
|
||||
eventID = result.Event.ID
|
||||
receiptID = result.Receipt.ID
|
||||
}
|
||||
if result.Event.ID != eventID || result.Receipt.ID != receiptID {
|
||||
t.Errorf("unstable identities: %#v", result)
|
||||
}
|
||||
}
|
||||
if seen != count {
|
||||
t.Fatalf("received %d results, want %d", seen, count)
|
||||
}
|
||||
conflict := command
|
||||
conflict.Location = "different"
|
||||
if _, err := service.Ingest(ctx, conflict); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, `UPDATE bell_events SET location='tampered' WHERE id=$1`, eventID); err == nil {
|
||||
t.Fatal("immutable Event accepted update")
|
||||
}
|
||||
db.Close()
|
||||
reopened, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
afterRestart, err := (Service{DB: reopened}).Ingest(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterRestart.Event.ID != eventID || afterRestart.Receipt.ID != receiptID || !afterRestart.Duplicate {
|
||||
t.Fatalf("receipt changed after restart: %#v", afterRestart)
|
||||
}
|
||||
failing := command
|
||||
failing.SourceEventID = key + "-rollback"
|
||||
failedService := Service{DB: reopened, AfterPersist: func(context.Context, pgx.Tx, Event) error { return errors.New("forced matching failure") }}
|
||||
if _, err := failedService.Ingest(ctx, failing); err == nil {
|
||||
t.Fatal("expected hook failure")
|
||||
}
|
||||
var persisted int
|
||||
if err := reopened.QueryRow(ctx, `SELECT count(*) FROM bell_events WHERE producer_id=$1 AND source_event_id=$2`, failing.ProducerID, failing.SourceEventID).Scan(&persisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted != 0 {
|
||||
t.Fatal("Event committed without atomic match")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/receipt"
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
EvidenceRef *string `json:"evidence_ref,omitempty"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Location string `json:"location"`
|
||||
Severity string `json:"severity"`
|
||||
EvidenceRef *string `json:"evidence_ref,omitempty"`
|
||||
NormalizedPayload json.RawMessage `json:"normalized_payload"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Event Event `json:"event"`
|
||||
Receipt receipt.Receipt `json:"receipt"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
DashboardRead = "dashboard:read"
|
||||
AlertsRead = "alerts:read"
|
||||
AlertsHandle = "alerts:handle"
|
||||
EventsRead = "events:read"
|
||||
RulesRead = "rules:read"
|
||||
RulesWrite = "rules:write"
|
||||
UsersRead = "users:read"
|
||||
UsersWrite = "users:write"
|
||||
AuditRead = "audit:read"
|
||||
SyntheticWrite = "synthetic:write"
|
||||
)
|
||||
|
||||
type Set map[string]struct{}
|
||||
|
||||
func (s Set) Has(permission string) bool { _, ok := s[permission]; return ok }
|
||||
|
||||
func (s Set) MarshalJSON() ([]byte, error) {
|
||||
values := make([]string, 0, len(s))
|
||||
for value := range s {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Strings(values)
|
||||
return json.Marshal(values)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package receipt
|
||||
|
||||
type Receipt struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
AcceptedAt string `json:"accepted_at"`
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
Audit audit.Store
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/rules", h.Auth.Require(rbac.RulesRead, http.HandlerFunc(h.list)))
|
||||
mux.Handle("POST /api/v1/rules", h.Auth.Require(rbac.RulesWrite, http.HandlerFunc(h.create)))
|
||||
mux.Handle("PUT /api/v1/rules/{id}/enabled", h.Auth.Require(rbac.RulesWrite, http.HandlerFunc(h.setEnabled)))
|
||||
}
|
||||
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Service.List(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取规则失败"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
}
|
||||
func (h HTTP) create(w http.ResponseWriter, r *http.Request) {
|
||||
var input CreateInput
|
||||
if err := decode(w, r, &input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
item, err := h.Service.Create(r.Context(), input)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
actor := auth.Principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rule.created", "rule", &item.ID, "success", map[string]any{"version": item.Version})
|
||||
writeJSON(w, 201, item)
|
||||
}
|
||||
func (h HTTP) setEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decode(w, r, &input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
item, err := h.Service.SetEnabled(r.Context(), r.PathValue("id"), input.Enabled)
|
||||
if err != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": "规则不存在"})
|
||||
return
|
||||
}
|
||||
actor := auth.Principal(r.Context())
|
||||
_ = h.Audit.Record(r.Context(), &actor.ID, "rule.enabled_changed", "rule", &item.ID, "success", map[string]any{"enabled": item.Enabled, "version": item.Version})
|
||||
writeJSON(w, 200, item)
|
||||
}
|
||||
func decode(w http.ResponseWriter, r *http.Request, value any) error {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(value)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("规则字段无效")
|
||||
|
||||
type Service struct{ DB *pgxpool.Pool }
|
||||
|
||||
func validSeverity(value string) bool {
|
||||
return value == "low" || value == "medium" || value == "high" || value == "critical"
|
||||
}
|
||||
func severityRank(value string) int {
|
||||
return map[string]int{"low": 1, "medium": 2, "high": 3, "critical": 4}[value]
|
||||
}
|
||||
func (s Service) Create(ctx context.Context, input CreateInput) (Rule, error) {
|
||||
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity))
|
||||
if input.Code == "" || input.Name == "" || !validSeverity(input.MinimumSeverity) {
|
||||
return Rule{}, ErrInvalid
|
||||
}
|
||||
if input.EventType != nil {
|
||||
v := strings.TrimSpace(*input.EventType)
|
||||
if v == "" {
|
||||
input.EventType = nil
|
||||
} else {
|
||||
input.EventType = &v
|
||||
}
|
||||
}
|
||||
if input.LocationContains != nil {
|
||||
v := strings.TrimSpace(*input.LocationContains)
|
||||
if v == "" {
|
||||
input.LocationContains = nil
|
||||
} else {
|
||||
input.LocationContains = &v
|
||||
}
|
||||
}
|
||||
var result Rule
|
||||
err := s.DB.QueryRow(ctx, `INSERT INTO bell_rules(code,name,event_type,minimum_severity,location_contains) VALUES($1,$2,$3,$4,$5) RETURNING id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text`, input.Code, input.Name, input.EventType, input.MinimumSeverity, input.LocationContains).Scan(&result.ID, &result.Code, &result.Name, &result.Enabled, &result.EventType, &result.MinimumSeverity, &result.LocationContains, &result.Version, &result.CreatedAt, &result.UpdatedAt)
|
||||
return result, err
|
||||
}
|
||||
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool) (Rule, error) {
|
||||
var result Rule
|
||||
err := s.DB.QueryRow(ctx, `UPDATE bell_rules SET enabled=$2,version=version+1,updated_at=now() WHERE id=$1 RETURNING id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text`, id, enabled).Scan(&result.ID, &result.Code, &result.Name, &result.Enabled, &result.EventType, &result.MinimumSeverity, &result.LocationContains, &result.Version, &result.CreatedAt, &result.UpdatedAt)
|
||||
return result, err
|
||||
}
|
||||
func (s Service) List(ctx context.Context) ([]Rule, error) {
|
||||
rows, err := s.DB.Query(ctx, `SELECT id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text FROM bell_rules ORDER BY created_at,id LIMIT 200`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Rule{}
|
||||
for rows.Next() {
|
||||
var item Rule
|
||||
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.EventType, &item.MinimumSeverity, &item.LocationContains, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s Service) Match(ctx context.Context, tx pgx.Tx, evt event.Event) error {
|
||||
rows, err := tx.Query(ctx, `SELECT id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text FROM bell_rules WHERE enabled=true ORDER BY id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules := []Rule{}
|
||||
for rows.Next() {
|
||||
var item Rule
|
||||
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.EventType, &item.MinimumSeverity, &item.LocationContains, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rules = append(rules, item)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range rules {
|
||||
matched := true
|
||||
reasons := []string{}
|
||||
if current.EventType != nil && *current.EventType != evt.EventType {
|
||||
matched = false
|
||||
reasons = append(reasons, "事件类型不匹配")
|
||||
}
|
||||
if severityRank(evt.Severity) < severityRank(current.MinimumSeverity) {
|
||||
matched = false
|
||||
reasons = append(reasons, "风险等级低于阈值")
|
||||
}
|
||||
if current.LocationContains != nil && !strings.Contains(strings.ToLower(evt.Location), strings.ToLower(*current.LocationContains)) {
|
||||
matched = false
|
||||
reasons = append(reasons, "地点条件不匹配")
|
||||
}
|
||||
explanation := "全部条件命中"
|
||||
if !matched {
|
||||
explanation = strings.Join(reasons, ";")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_rule_evaluations(event_id,rule_id,rule_version,matched,explanation) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, evt.ID, current.ID, current.Version, matched, explanation); err != nil {
|
||||
return err
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
snapshot, _ := json.Marshal(current)
|
||||
summary := fmt.Sprintf("%s:%s", current.Name, evt.EventType)
|
||||
var alertID string
|
||||
err := tx.QueryRow(ctx, `INSERT INTO bell_alerts(primary_rule_id,correlation_key,severity,summary,location) VALUES($1,$2,$3,$4,$5) ON CONFLICT(primary_rule_id,correlation_key) WHERE status='open' DO UPDATE SET updated_at=now(),severity=CASE WHEN array_position(ARRAY['low','medium','high','critical'],EXCLUDED.severity)>array_position(ARRAY['low','medium','high','critical'],bell_alerts.severity) THEN EXCLUDED.severity ELSE bell_alerts.severity END RETURNING id::text`, current.ID, strings.ToLower(evt.Location), evt.Severity, summary, evt.Location).Scan(&alertID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_alert_events(alert_id,event_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, alertID, evt.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bell_rule_matches(alert_id,event_id,rule_id,rule_version,rule_snapshot,explanation) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, alertID, evt.ID, current.ID, current.Version, snapshot, explanation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
func TestPostgresMatchingAndNavigationShape(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := Service{DB: db}
|
||||
prefix := fmt.Sprintf("rule-test-%d", time.Now().UnixNano())
|
||||
eventType := "rule-test-danger"
|
||||
location := "north"
|
||||
first, err := service.Create(ctx, CreateInput{Code: prefix + "-one", Name: "规则一", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.Create(ctx, CreateInput{Code: prefix + "-two", Name: "规则二", EventType: &eventType, MinimumSeverity: "low"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventService := event.Service{DB: db, AfterPersist: service.Match}
|
||||
base := event.Command{ProducerID: prefix, SourceEventID: "1", EventType: eventType, OccurredAt: time.Now().UTC(), Location: "north gate", Severity: "high", Attributes: map[string]any{}}
|
||||
one, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.SourceEventID = "2"
|
||||
two, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var alertCount, linkCount int
|
||||
if err := db.QueryRow(ctx, `SELECT count(DISTINCT m.alert_id),count(*) FROM bell_rule_matches m WHERE m.rule_id=ANY($1::uuid[])`, []string{first.ID, second.ID}).Scan(&alertCount, &linkCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alertCount != 2 || linkCount != 4 {
|
||||
t.Fatalf("alerts=%d links=%d", alertCount, linkCount)
|
||||
}
|
||||
var shared int
|
||||
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_events a JOIN bell_alert_events b ON b.alert_id=a.alert_id JOIN bell_alerts current ON current.id=a.alert_id WHERE a.event_id=$1 AND b.event_id=$2 AND current.primary_rule_id=ANY($3::uuid[])`, one.Event.ID, two.Event.ID, []string{first.ID, second.ID}).Scan(&shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shared != 2 {
|
||||
t.Fatalf("expected two correlated alerts, got %d", shared)
|
||||
}
|
||||
base.SourceEventID = "3"
|
||||
base.EventType = "unmatched"
|
||||
unmatched, err := eventService.Ingest(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var evaluations, matches int
|
||||
if err := db.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE matched) FROM bell_rule_evaluations WHERE event_id=$1`, unmatched.Event.ID).Scan(&evaluations, &matches); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if evaluations < 2 || matches != 0 {
|
||||
t.Fatalf("evaluations=%d matches=%d", evaluations, matches)
|
||||
}
|
||||
disabled, err := service.SetEnabled(ctx, first.ID, false)
|
||||
if err != nil || disabled.Enabled || disabled.Version != 2 {
|
||||
t.Fatalf("disable: %#v %v", disabled, err)
|
||||
}
|
||||
enabled, err := service.SetEnabled(ctx, first.ID, true)
|
||||
if err != nil || !enabled.Enabled || enabled.Version != 3 {
|
||||
t.Fatalf("enable: %#v %v", enabled, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package rule
|
||||
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
MinimumSeverity string `json:"minimum_severity"`
|
||||
LocationContains *string `json:"location_contains,omitempty"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
type CreateInput struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
EventType *string `json:"event_type,omitempty"`
|
||||
MinimumSeverity string `json:"minimum_severity"`
|
||||
LocationContains *string `json:"location_contains,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
||||
)
|
||||
|
||||
type HTTP struct {
|
||||
Service Service
|
||||
Auth auth.HTTP
|
||||
}
|
||||
|
||||
func (h HTTP) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/synthetic/fixtures", h.Auth.Require(rbac.SyntheticWrite, http.HandlerFunc(h.fixtures)))
|
||||
mux.Handle("POST /api/v1/synthetic/events", h.Auth.Require(rbac.SyntheticWrite, http.HandlerFunc(h.inject)))
|
||||
}
|
||||
func (h HTTP) fixtures(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := h.Service.Fixtures()
|
||||
if err != nil {
|
||||
writeJSON(w, 500, map[string]string{"error": "读取夹具失败"})
|
||||
return
|
||||
}
|
||||
type summary struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
ExpectedFailure bool `json:"expected_failure"`
|
||||
}
|
||||
result := make([]summary, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, summary{Name: item.Name, Label: item.Label, ExpectedFailure: item.ValidationError != ""})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": result})
|
||||
}
|
||||
func (h HTTP) inject(w http.ResponseWriter, r *http.Request) {
|
||||
var input Input
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
|
||||
return
|
||||
}
|
||||
result, err := h.Service.Inject(r.Context(), input)
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, ErrFixture) || errors.Is(err, event.ErrInvalid) {
|
||||
status = 400
|
||||
} else if errors.Is(err, event.ErrConflict) {
|
||||
status = 409
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
status := 201
|
||||
if result.Duplicate {
|
||||
status = 200
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
testevents "git.ilapage.cn/ila/yovision/Bell/server/testdata/events"
|
||||
)
|
||||
|
||||
var ErrFixture = errors.New("合成夹具验证失败")
|
||||
|
||||
type Service struct{ Events event.Service }
|
||||
type Input struct {
|
||||
Fixture string `json:"fixture"`
|
||||
SourceEventID string `json:"source_event_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s Service) Fixtures() ([]testevents.Fixture, error) { return testevents.All() }
|
||||
func (s Service) Inject(ctx context.Context, input Input) (event.Result, error) {
|
||||
fixture, err := testevents.Find(input.Fixture)
|
||||
if err != nil {
|
||||
return event.Result{}, err
|
||||
}
|
||||
if fixture.ValidationError != "" {
|
||||
return event.Result{}, errors.Join(ErrFixture, errors.New(fixture.ValidationError))
|
||||
}
|
||||
if value := strings.TrimSpace(input.SourceEventID); value != "" {
|
||||
fixture.Command.SourceEventID = value
|
||||
}
|
||||
return s.Events.Ingest(ctx, fixture.Command)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
func TestInvalidFixturesAreStable(t *testing.T) {
|
||||
service := Service{}
|
||||
for _, name := range []string{"invalid-fields", "missing-evidence"} {
|
||||
if _, err := service.Inject(context.Background(), Input{Fixture: name}); !errors.Is(err, ErrFixture) {
|
||||
t.Fatalf("fixture %s: got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestFixtureUsesEventService(t *testing.T) {
|
||||
url := os.Getenv("BELL_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("BELL_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
db, err := platform.OpenDatabase(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := Service{Events: event.Service{DB: db}}
|
||||
source := fmt.Sprintf("synthetic-%d", time.Now().UnixNano())
|
||||
first, err := service.Inject(ctx, Input{Fixture: "danger-zone", SourceEventID: source})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.Inject(ctx, Input{Fixture: "danger-zone", SourceEventID: source})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Event.ID != second.Event.ID || !second.Duplicate {
|
||||
t.Fatalf("fixture bypassed idempotency: %#v %#v", first, second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package bell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
alertLifecycle "git.ilapage.cn/ila/yovision/Bell/server/app/alert/lifecycle"
|
||||
alertQuery "git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/rule"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/app/synthetic"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/config"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
|
||||
)
|
||||
|
||||
const Version = "0.1.0"
|
||||
|
||||
func Run(ctx context.Context, args []string) error {
|
||||
command := "serve"
|
||||
if len(args) > 0 {
|
||||
command = args[0]
|
||||
}
|
||||
if command == "version" {
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
}
|
||||
if command != "serve" && command != "migrate" && command != "create-admin" {
|
||||
return fmt.Errorf("unknown command %q (use serve, migrate, create-admin, or version)", command)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := platform.OpenDatabase(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrations.Apply(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if command == "migrate" {
|
||||
return nil
|
||||
}
|
||||
auditStore := audit.Store{DB: db}
|
||||
authStore := auth.Store{DB: db}
|
||||
authService := auth.Service{Store: authStore, Audit: auditStore, Secret: []byte(cfg.SessionSecret), SessionTTL: 8 * time.Hour}
|
||||
if command == "create-admin" {
|
||||
username, displayName := argument(args, "--username"), argument(args, "--display-name")
|
||||
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("BELL_BOOTSTRAP_PASSWORD is required for create-admin")
|
||||
}
|
||||
return authService.BootstrapAdministrator(ctx, username, displayName, password)
|
||||
}
|
||||
app := platform.NewHTTPApp(db)
|
||||
authHTTP := auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}
|
||||
authHTTP.Register(app.Router())
|
||||
ruleService := rule.Service{DB: db}
|
||||
eventService := event.Service{DB: db, AfterPersist: ruleService.Match}
|
||||
event.HTTP{Service: eventService, Auth: authHTTP}.Register(app.Router())
|
||||
rule.HTTP{Service: ruleService, Auth: authHTTP, Audit: auditStore}.Register(app.Router())
|
||||
alertQueryService := alertQuery.Service{DB: db}
|
||||
alertQuery.HTTP{Service: alertQueryService, Auth: authHTTP}.Register(app.Router())
|
||||
alertLifecycle.HTTP{Service: alertLifecycle.Service{DB: db, Audit: auditStore, Query: alertQueryService}, Auth: authHTTP}.Register(app.Router())
|
||||
if cfg.Environment == "development" || cfg.Environment == "test" {
|
||||
synthetic.HTTP{Service: synthetic.Service{Events: eventService}, Auth: authHTTP}.Register(app.Router())
|
||||
}
|
||||
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
|
||||
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go func() {
|
||||
<-serverCtx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
slog.Info("Bell listening", "address", cfg.HTTPAddress, "environment", cfg.Environment)
|
||||
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func argument(args []string, name string) string {
|
||||
for i := 1; i < len(args)-1; i++ {
|
||||
if strings.EqualFold(args[i], name) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string
|
||||
HTTPAddress string
|
||||
DatabaseURL string
|
||||
SessionSecret string
|
||||
CookieSecure bool
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Environment: value("BELL_ENV", "development"),
|
||||
HTTPAddress: value("BELL_HTTP_ADDRESS", "127.0.0.1:8082"),
|
||||
DatabaseURL: os.Getenv("BELL_DATABASE_URL"),
|
||||
SessionSecret: os.Getenv("BELL_SESSION_SECRET"),
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
}
|
||||
if raw := os.Getenv("BELL_SHUTDOWN_SECONDS"); raw != "" {
|
||||
seconds, err := strconv.Atoi(raw)
|
||||
if err != nil || seconds < 1 || seconds > 300 {
|
||||
return Config{}, fmt.Errorf("BELL_SHUTDOWN_SECONDS must be between 1 and 300")
|
||||
}
|
||||
cfg.ShutdownTimeout = time.Duration(seconds) * time.Second
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("BELL_DATABASE_URL is required")
|
||||
}
|
||||
if len(cfg.SessionSecret) < 32 {
|
||||
return Config{}, fmt.Errorf("BELL_SESSION_SECRET must contain at least 32 characters")
|
||||
}
|
||||
cfg.CookieSecure = cfg.Environment == "production"
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func value(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module git.ilapage.cn/ila/yovision/Bell/server
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/crypto v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,30 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func OpenDatabase(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse Bell database URL: %w", err)
|
||||
}
|
||||
poolConfig.MaxConns = 8
|
||||
poolConfig.MinConns = 1
|
||||
poolConfig.MaxConnLifetime = 30 * time.Minute
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open Bell database: %w", err)
|
||||
}
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping Bell database: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type HTTPApp struct {
|
||||
DB *pgxpool.Pool
|
||||
Mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewHTTPApp(db *pgxpool.Pool) *HTTPApp {
|
||||
app := &HTTPApp{DB: db, Mux: http.NewServeMux()}
|
||||
app.Mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "bell", "time": time.Now().UTC()})
|
||||
})
|
||||
app.Mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := db.Ping(r.Context()); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *HTTPApp) Router() *http.ServeMux { return a.Mux }
|
||||
|
||||
func (a *HTTPApp) Handler() http.Handler {
|
||||
return securityHeaders(a.Mux)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
h := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
recorder := httptest.NewRecorder()
|
||||
h.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if recorder.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Fatal("missing frame protection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Bell is derived from the frozen go-admin baseline. See Bell/LICENSES/SOURCES.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Bell/server/cmd/bell"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := bell.Run(context.Background(), os.Args[1:]); err != nil {
|
||||
slog.Error("bell stopped", "error", err)
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS bell_runtime_probe (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
started_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE bell_roles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE CHECK (code IN ('administrator','operator','viewer')),
|
||||
name text NOT NULL
|
||||
);
|
||||
CREATE TABLE bell_permissions (code text PRIMARY KEY, description text NOT NULL);
|
||||
CREATE TABLE bell_role_permissions (
|
||||
role_id uuid NOT NULL REFERENCES bell_roles(id),
|
||||
permission_code text NOT NULL REFERENCES bell_permissions(code),
|
||||
PRIMARY KEY (role_id, permission_code)
|
||||
);
|
||||
CREATE TABLE bell_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username text NOT NULL UNIQUE CHECK (username = lower(username)),
|
||||
display_name text NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE bell_user_roles (
|
||||
user_id uuid NOT NULL REFERENCES bell_users(id),
|
||||
role_id uuid NOT NULL REFERENCES bell_roles(id),
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
CREATE TABLE bell_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES bell_users(id),
|
||||
token_digest bytea NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz,
|
||||
CHECK (expires_at > created_at)
|
||||
);
|
||||
CREATE INDEX bell_sessions_user_active_idx ON bell_sessions(user_id, expires_at) WHERE revoked_at IS NULL;
|
||||
CREATE TABLE bell_audit_log (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
actor_user_id uuid REFERENCES bell_users(id),
|
||||
action text NOT NULL,
|
||||
target_type text NOT NULL,
|
||||
target_id text,
|
||||
outcome text NOT NULL CHECK (outcome IN ('success','failure','denied')),
|
||||
details jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION bell_reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'bell audit facts are append-only'; END $$;
|
||||
CREATE TRIGGER bell_audit_no_update BEFORE UPDATE OR DELETE ON bell_audit_log FOR EACH ROW EXECUTE FUNCTION bell_reject_audit_mutation();
|
||||
|
||||
INSERT INTO bell_roles(code,name) VALUES ('administrator','管理员'),('operator','处置员'),('viewer','只读用户');
|
||||
INSERT INTO bell_permissions(code,description) VALUES
|
||||
('dashboard:read','查看工作台'),('alerts:read','查看预警'),('alerts:handle','处置预警'),
|
||||
('events:read','查看事件'),('rules:read','查看规则'),('rules:write','管理规则'),
|
||||
('users:read','查看用户'),('users:write','管理用户与角色'),('audit:read','查看审计');
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r CROSS JOIN bell_permissions p WHERE r.code='administrator';
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','alerts:handle','events:read','rules:read') WHERE r.code='operator';
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','events:read','rules:read') WHERE r.code='viewer';
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE bell_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
producer_id text NOT NULL,
|
||||
source_event_id text NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
location text NOT NULL,
|
||||
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
|
||||
evidence_ref text,
|
||||
normalized_payload jsonb NOT NULL,
|
||||
payload_sha256 bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (producer_id, source_event_id)
|
||||
);
|
||||
CREATE INDEX bell_events_created_page_idx ON bell_events(created_at DESC,id DESC);
|
||||
CREATE TABLE bell_event_receipts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id uuid NOT NULL UNIQUE REFERENCES bell_events(id),
|
||||
producer_id text NOT NULL,
|
||||
source_event_id text NOT NULL,
|
||||
payload_sha256 bytea NOT NULL,
|
||||
accepted_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (producer_id, source_event_id)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION bell_reject_event_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'Bell Event and Receipt facts are immutable'; END $$;
|
||||
CREATE TRIGGER bell_event_no_update BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
|
||||
CREATE TRIGGER bell_receipt_no_update BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
|
||||
@@ -0,0 +1,3 @@
|
||||
INSERT INTO bell_permissions(code,description) VALUES ('synthetic:write','注入开发测试合成事件');
|
||||
INSERT INTO bell_role_permissions(role_id,permission_code)
|
||||
SELECT id,'synthetic:write' FROM bell_roles WHERE code='administrator';
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE bell_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
event_type text,
|
||||
minimum_severity text NOT NULL CHECK (minimum_severity IN ('low','medium','high','critical')),
|
||||
location_contains text,
|
||||
version integer NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE bell_alerts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
primary_rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
correlation_key text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','acknowledged','closed')),
|
||||
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
|
||||
summary text NOT NULL,
|
||||
location text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id,correlation_key) WHERE status='open';
|
||||
CREATE INDEX bell_alerts_page_idx ON bell_alerts(created_at DESC,id DESC);
|
||||
CREATE TABLE bell_alert_events (
|
||||
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
linked_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(alert_id,event_id)
|
||||
);
|
||||
CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id,alert_id);
|
||||
CREATE TABLE bell_rule_matches (
|
||||
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
rule_version integer NOT NULL,
|
||||
rule_snapshot jsonb NOT NULL,
|
||||
explanation text NOT NULL,
|
||||
matched_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(event_id,rule_id)
|
||||
);
|
||||
CREATE TABLE bell_rule_evaluations (
|
||||
event_id uuid NOT NULL REFERENCES bell_events(id),
|
||||
rule_id uuid NOT NULL REFERENCES bell_rules(id),
|
||||
rule_version integer NOT NULL,
|
||||
matched boolean NOT NULL,
|
||||
explanation text NOT NULL,
|
||||
evaluated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(event_id,rule_id)
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
ALTER TABLE bell_alerts
|
||||
ADD COLUMN acknowledged_by uuid REFERENCES bell_users(id),
|
||||
ADD COLUMN acknowledged_at timestamptz,
|
||||
ADD COLUMN closed_by uuid REFERENCES bell_users(id),
|
||||
ADD COLUMN closed_at timestamptz,
|
||||
ADD COLUMN close_outcome text CHECK (close_outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm')),
|
||||
ADD COLUMN close_note text;
|
||||
CREATE TABLE bell_alert_lifecycle_facts (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
|
||||
transition text NOT NULL CHECK (transition IN ('acknowledged','closed')),
|
||||
actor_user_id uuid NOT NULL REFERENCES bell_users(id),
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE(alert_id,transition)
|
||||
);
|
||||
CREATE INDEX bell_alert_lifecycle_timeline_idx ON bell_alert_lifecycle_facts(alert_id,id);
|
||||
CREATE TRIGGER bell_alert_lifecycle_no_update BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_audit_mutation();
|
||||
@@ -0,0 +1,67 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed *.sql
|
||||
var files embed.FS
|
||||
|
||||
func Apply(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire migration connection: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtext('yovision-bell-migrations'))`); err != nil {
|
||||
return fmt.Errorf("lock migrations: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext('yovision-bell-migrations'))`)
|
||||
}()
|
||||
if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS bell_schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
return fmt.Errorf("create migration ledger: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(files, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || len(entry.Name()) < 4 || entry.Name()[len(entry.Name())-4:] != ".sql" {
|
||||
continue
|
||||
}
|
||||
var applied bool
|
||||
if err := conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bell_schema_migrations WHERE name=$1)`, entry.Name()).Scan(&applied); err != nil {
|
||||
return err
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
sqlBytes, err := files.ReadFile(entry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, string(sqlBytes)); err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO bell_schema_migrations(name) VALUES($1)`, entry.Name())
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"danger-zone","label":"危险区域进入","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-danger-zone","event_type":"danger-zone-entry","occurred_at":"2026-08-12T09:00:00Z","location":"北侧围墙","severity":"high","evidence_ref":"evidence://synthetic/danger-zone","attributes":{"object":"anonymous-person","zone":"north-wall"}}}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
bellEvent "git.ilapage.cn/ila/yovision/Bell/server/app/event"
|
||||
)
|
||||
|
||||
//go:embed *.json
|
||||
var files embed.FS
|
||||
|
||||
type Fixture struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
ValidationError string `json:"validation_error,omitempty"`
|
||||
Command bellEvent.Command `json:"command"`
|
||||
}
|
||||
|
||||
func All() ([]Fixture, error) {
|
||||
entries, err := fs.ReadDir(files, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []Fixture{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := files.ReadFile(entry.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var item Fixture
|
||||
if err := json.Unmarshal(data, &item); err != nil {
|
||||
return nil, fmt.Errorf("decode fixture %s: %w", entry.Name(), err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
||||
return items, nil
|
||||
}
|
||||
func Find(name string) (Fixture, error) {
|
||||
items, err := All()
|
||||
if err != nil {
|
||||
return Fixture{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Name == name {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return Fixture{}, fmt.Errorf("unknown synthetic fixture %q", name)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"invalid-fields","label":"字段无效(预期失败)","validation_error":"夹具用于验证字段拒绝","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-invalid","event_type":"","occurred_at":"2026-08-12T09:02:00Z","location":"测试区域","severity":"unknown","attributes":{}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"line-crossing","label":"方向越线","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-line-crossing","event_type":"line-crossing","occurred_at":"2026-08-12T09:01:00Z","location":"宿舍楼东门","severity":"critical","evidence_ref":"evidence://synthetic/line-crossing","attributes":{"direction":"outside-to-inside","object":"anonymous-person"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"missing-evidence","label":"证据缺失(预期失败)","validation_error":"此测试场景要求证据引用","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-missing-evidence","event_type":"danger-zone-entry","occurred_at":"2026-08-12T09:03:00Z","location":"测试区域","severity":"high","attributes":{"requires_evidence":true}}}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { node: true },
|
||||
extends: ['plugin:vue/vue3-essential', 'eslint:recommended'],
|
||||
parserOptions: { parser: '@babel/eslint-parser', requireConfigFile: false },
|
||||
rules: { 'vue/multi-word-component-names': 'off' }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { presets: ['@vue/cli-plugin-babel/preset'] }
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "yovision-bell-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vue-cli-service serve",
|
||||
"lint": "eslint --ext .js,.vue src",
|
||||
"build:prod": "vue-cli-service build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "2.3.2",
|
||||
"axios": "1.19.0",
|
||||
"core-js": "3.50.0",
|
||||
"element-plus": "2.14.4",
|
||||
"vue": "3.5.41",
|
||||
"vue-router": "4.6.4",
|
||||
"vuex": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.29.7",
|
||||
"@vue/cli-plugin-babel": "5.0.9",
|
||||
"@vue/cli-plugin-eslint": "5.0.9",
|
||||
"@vue/cli-service": "5.0.9",
|
||||
"@vue/compiler-sfc": "3.5.41",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-plugin-vue": "9.33.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.1",
|
||||
"engines": { "node": "22.22.1", "pnpm": "9.15.1" }
|
||||
}
|
||||
Generated
+8334
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bell 预警中心</title></head>
|
||||
<body><noscript>Bell 需要启用 JavaScript。</noscript><div id="app"></div></body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listAlerts = params => request.get('/api/v1/alerts', { params })
|
||||
export const getAlert = id => request.get(`/api/v1/alerts/${id}`)
|
||||
export const alertsForEvent = id => request.get(`/api/v1/events/${id}/alerts`)
|
||||
export const acknowledgeAlert = id => request.post(`/api/v1/alerts/${id}/ack`)
|
||||
export const closeAlert = (id, data) => request.post(`/api/v1/alerts/${id}/close`, data)
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from '../../bootstrap/request'
|
||||
|
||||
export const login = data => request.post('/api/v1/auth/login', data)
|
||||
export const logout = () => request.post('/api/v1/auth/logout')
|
||||
export const currentUser = () => request.get('/api/v1/auth/me')
|
||||
export const listUsers = () => request.get('/api/v1/users')
|
||||
export const createUser = data => request.post('/api/v1/users', data)
|
||||
export const replaceRoles = (id, roles) => request.put(`/api/v1/users/${id}/roles`, { roles })
|
||||
export const listAudit = () => request.get('/api/v1/audit?limit=100')
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listEvents = params => request.get('/api/v1/events', { params })
|
||||
export const getEvent = id => request.get(`/api/v1/events/${id}`)
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listRules = () => request.get('/api/v1/rules')
|
||||
export const createRule = data => request.post('/api/v1/rules', data)
|
||||
export const setRuleEnabled = (id, enabled) => request.put(`/api/v1/rules/${id}/enabled`, { enabled })
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '../../bootstrap/request'
|
||||
export const listFixtures = () => request.get('/api/v1/synthetic/fixtures')
|
||||
export const injectSynthetic = data => request.post('/api/v1/synthetic/events', data)
|
||||
@@ -0,0 +1,6 @@
|
||||
<template><router-view v-if="route.meta.public" /><AppLayout v-else><router-view /></AppLayout></template>
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppLayout from '../layout/AppLayout.vue'
|
||||
const route = useRoute()
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<main class="page" tabindex="-1">
|
||||
<header class="page__header"><div><p class="eyebrow">BELL</p><h1>预警工作台</h1><p>Bell 已独立运行,业务模块将按工单顺序启用。</p></div><el-tag type="success">服务就绪</el-tag></header>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>待处理预警</template><strong class="metric">0</strong><p>规则模块启用后显示。</p></el-card></el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>今日事件</template><strong class="metric">0</strong><p>事件入站启用后显示。</p></el-card></el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>运行状态</template><strong class="metric metric--small">独立</strong><p>无需 Sense 或 Brain 在线。</p></el-card></el-col>
|
||||
</el-row>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import './theme.css'
|
||||
|
||||
createApp(App).use(store).use(router).use(ElementPlus).mount('#app')
|
||||
@@ -0,0 +1,5 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({ timeout: 10000, withCredentials: true, headers: { 'Content-Type': 'application/json' } })
|
||||
request.interceptors.response.use(response => response.data, error => Promise.reject(error.response?.data || { error: '无法连接 Bell 服务' }))
|
||||
export default request
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from './Dashboard.vue'
|
||||
import Login from '../views/login/Login.vue'
|
||||
import Users from '../views/system/Users.vue'
|
||||
import Audit from '../views/system/Audit.vue'
|
||||
import Events from '../views/event/Events.vue'
|
||||
import SyntheticEvent from '../views/synthetic-event/SyntheticEvent.vue'
|
||||
import Rules from '../views/rule/Rules.vue'
|
||||
import Alerts from '../views/alert/list/Alerts.vue'
|
||||
import { listFixtures } from '../api/synthetic'
|
||||
import store from './store'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: Login, meta: { title: '登录', public: true } },
|
||||
{ path: '/', name: 'dashboard', component: Dashboard, meta: { title: '工作台', permission: 'dashboard:read' } },
|
||||
{ path: '/events', name: 'events', component: Events, meta: { title: '事件查询', permission: 'events:read' } },
|
||||
{ path: '/alerts', name: 'alerts', component: Alerts, meta: { title: '预警管理', permission: 'alerts:read' } },
|
||||
{ path: '/rules', name: 'rules', component: Rules, meta: { title: '规则配置', permission: 'rules:read' } },
|
||||
{ path: '/development/synthetic-events', name: 'synthetic-events', component: SyntheticEvent, meta: { title: '合成事件测试', permission: 'synthetic:write', developmentOnly: true } },
|
||||
{ path: '/system/users', name: 'users', component: Users, meta: { title: '用户与角色', permission: 'users:read' } },
|
||||
{ path: '/system/audit', name: 'audit', component: Audit, meta: { title: '认证审计', permission: 'audit:read' } }
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach(async to => {
|
||||
if (!store.state.identity.checked) await store.dispatch('identity/restore')
|
||||
const user = store.state.identity.user
|
||||
if (to.meta.public) return user && to.name === 'login' ? { name: 'dashboard' } : true
|
||||
if (!user) return { name: 'login', query: { redirect: to.fullPath } }
|
||||
if (to.meta.permission && !user.permissions?.includes(to.meta.permission)) return { name: 'dashboard' }
|
||||
if (to.meta.developmentOnly) { try { await listFixtures() } catch (_) { return { name: 'dashboard' } } }
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createStore } from 'vuex'
|
||||
import identity from '../store/modules/identity'
|
||||
|
||||
export default createStore({ state: () => ({ product: 'Bell' }), modules: { identity } })
|
||||
@@ -0,0 +1,26 @@
|
||||
:root { --bell-accent:#409eff; --bell-bg:#f3f4f7; --bell-surface:#fff; --bell-text:#1f2329; --bell-muted:#6b7280; --bell-stroke:#e5e7eb; }
|
||||
* { box-sizing:border-box; }
|
||||
html, body, #app { min-height:100%; margin:0; }
|
||||
body { font-family:"Segoe UI Variable","Microsoft YaHei",sans-serif; color:var(--bell-text); background:var(--bell-bg); }
|
||||
.shell { min-height:100vh; display:grid; grid-template-columns:220px minmax(0,1fr); }
|
||||
.sidebar { background:#263445; color:#fff; padding:16px 12px; }
|
||||
.brand { display:flex; gap:12px; align-items:center; min-height:48px; padding:0 8px 16px; }
|
||||
.brand__mark { display:grid; place-items:center; width:36px; height:36px; border-radius:8px; background:var(--bell-accent); font-weight:700; }
|
||||
.brand small { display:block; color:#cbd5e1; margin-top:2px; }
|
||||
.nav-item { display:flex; min-height:44px; align-items:center; padding:0 12px; color:#cbd5e1; text-decoration:none; border-radius:6px; }
|
||||
.nav-item.router-link-active { color:#fff; background:#1f2d3d; }
|
||||
.workspace { min-width:0; }
|
||||
.navbar { height:50px; display:flex; align-items:center; justify-content:space-between; padding:0 20px; background:var(--bell-surface); border-bottom:1px solid var(--bell-stroke); }
|
||||
.navbar__identity { color:var(--bell-muted); display:flex; align-items:center; gap:12px; }
|
||||
.tags-view { height:34px; display:flex; align-items:end; padding:0 16px; background:#fff; border-bottom:1px solid var(--bell-stroke); }
|
||||
.tag-current { padding:6px 12px; border:1px solid var(--bell-stroke); border-bottom:2px solid var(--bell-accent); }
|
||||
.app-main { padding:20px; }
|
||||
.page { max-width:1440px; margin:0 auto; outline:none; }
|
||||
.page__header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:20px; }
|
||||
.page__header h1 { margin:0 0 6px; font-size:24px; }
|
||||
.page__header p { margin:0; color:var(--bell-muted); }
|
||||
.eyebrow { color:var(--bell-accent)!important; font-size:12px; font-weight:700; letter-spacing:.12em; }
|
||||
.metric { font-size:36px; font-variant-numeric:tabular-nums; }
|
||||
.metric--small { font-size:24px; }
|
||||
@media (max-width:640px) { .shell { grid-template-columns:1fr; } .sidebar { position:static; padding:8px 12px; } .brand { padding-bottom:8px; } .sidebar nav { display:flex; } .workspace { min-width:0; } .app-main { padding:12px; } .page__header { flex-direction:column; } }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition-duration:.01ms!important; animation-duration:.01ms!important; } }
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="shell">
|
||||
<aside class="sidebar" aria-label="主导航">
|
||||
<div class="brand"><span class="brand__mark">B</span><div><strong>Bell</strong><small>预警中心</small></div></div>
|
||||
<nav>
|
||||
<router-link to="/" class="nav-item">工作台</router-link>
|
||||
<router-link v-if="has('alerts:read')" to="/alerts" class="nav-item">预警管理</router-link>
|
||||
<router-link v-if="has('events:read')" to="/events" class="nav-item">事件查询</router-link>
|
||||
<router-link v-if="has('rules:read')" to="/rules" class="nav-item">规则配置</router-link>
|
||||
<router-link v-if="has('users:read')" to="/system/users" class="nav-item">用户与角色</router-link>
|
||||
<router-link v-if="has('audit:read')" to="/system/audit" class="nav-item">认证审计</router-link>
|
||||
<router-link v-if="syntheticAvailable" to="/development/synthetic-events" class="nav-item">合成事件测试</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<header class="navbar"><span>学校安全预警</span><div class="navbar__identity"><span>{{ user?.display_name }}</span><el-button link @click="signOut">退出</el-button></div></header>
|
||||
<div class="tags-view"><span class="tag-current">{{ route.meta.title }}</span></div>
|
||||
<div class="app-main"><slot /></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed,onMounted,ref } from 'vue'
|
||||
import { useRoute,useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
import { listFixtures } from '../api/synthetic'
|
||||
const store=useStore();const route=useRoute();const router=useRouter();const user=computed(()=>store.state.identity.user);const syntheticAvailable=ref(false);const has=permission=>store.getters['identity/has'](permission);onMounted(async()=>{if(has('synthetic:write')){try{await listFixtures();syntheticAvailable.value=true}catch(_){syntheticAvailable.value=false}}});async function signOut(){await store.dispatch('identity/logout');await router.replace('/login')}
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
import './bootstrap/main'
|
||||
@@ -0,0 +1,13 @@
|
||||
import { currentUser, login, logout } from '../../api/auth'
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: () => ({ user: null, checked: false }),
|
||||
getters: { has: state => permission => Boolean(state.user?.permissions?.includes(permission)) },
|
||||
mutations: { setUser (state, user) { state.user = user; state.checked = true } },
|
||||
actions: {
|
||||
async restore ({ commit }) { try { commit('setUser', await currentUser()) } catch (_) { commit('setUser', null) } },
|
||||
async login ({ commit }, form) { const result = await login(form); commit('setUser', result.user); return result.user },
|
||||
async logout ({ commit }) { try { await logout() } finally { commit('setUser', null) } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<main class="page" tabindex="-1">
|
||||
<header class="page__header"><div><h1>预警管理</h1><p>先确认已看到并开始处理,再记录现场结果完成预警。</p></div></header>
|
||||
<el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open">
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="180"/><el-table-column prop="summary" label="预警事项" min-width="190"/><el-table-column prop="location" label="地点" min-width="130"/>
|
||||
<el-table-column label="状态"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{statusName(scope.row.status)}}</el-tag></template></el-table-column>
|
||||
<el-table-column label="处理人" min-width="120"><template #default="scope">{{scope.row.acknowledged_by_name||'待确认'}}</template></el-table-column><el-table-column prop="event_count" label="关联事件" width="100"/>
|
||||
<el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading&&!items.length" description="尚无预警"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div>
|
||||
<el-drawer v-model="drawer" title="预警详情" size="min(640px, 100%)">
|
||||
<template v-if="detail">
|
||||
<el-alert v-if="actionError" :title="actionError" type="warning" show-icon :closable="false"/>
|
||||
<el-descriptions :column="1" border><el-descriptions-item label="事项">{{detail.alert.summary}}</el-descriptions-item><el-descriptions-item label="地点">{{detail.alert.location}}</el-descriptions-item><el-descriptions-item label="规则">{{detail.alert.rule_name}}</el-descriptions-item><el-descriptions-item label="状态">{{statusName(detail.alert.status)}}</el-descriptions-item><el-descriptions-item label="处理人">{{detail.alert.acknowledged_by_name||'尚未开始处理'}}</el-descriptions-item><el-descriptions-item v-if="detail.alert.close_outcome" label="现场结果">{{outcomeName(detail.alert.close_outcome)}}</el-descriptions-item><el-descriptions-item v-if="detail.alert.close_note" label="处理说明">{{detail.alert.close_note}}</el-descriptions-item></el-descriptions>
|
||||
<div class="action-bar" v-if="canHandle"><el-button v-if="detail.alert.status==='open'" type="primary" :loading="acting" @click="ack">我已看到并开始处理</el-button><el-button v-if="canClose" type="primary" :loading="acting" @click="closeDialog=true">记录现场结果并完成</el-button></div>
|
||||
<h2>关联事件</h2><el-table :data="detail.events" row-key="id"><el-table-column prop="occurred_at" label="发生时间"/><el-table-column prop="event_type" label="类型"/><el-table-column label="操作" width="80"><template #default="scope"><el-button link @click="router.push({name:'events',query:{event:scope.row.id}})">查看</el-button></template></el-table-column></el-table>
|
||||
<h2>处理时间线</h2><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="entry.id" :timestamp="entry.occurred_at">{{entry.transition==='acknowledged'?'开始处理':'处理完成'}} · {{entry.actor_name}}</el-timeline-item><el-timeline-item v-for="match in detail.matches" :key="`${match.event_id}-${match.rule_id}`" :timestamp="match.matched_at" type="primary">规则 v{{match.rule_version}}:{{match.explanation}}</el-timeline-item></el-timeline>
|
||||
</template>
|
||||
</el-drawer>
|
||||
<el-dialog v-model="closeDialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" @closed="resetClose"><el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false"/><el-form ref="closeFormRef" :model="closeForm" :rules="closeRules" label-position="top"><el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="closeForm.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item><el-form-item label="补充说明(可选)"><el-input v-model="closeForm.note" type="textarea" :rows="3" maxlength="500" show-word-limit/></el-form-item></el-form><template #footer><el-button @click="closeDialog=false">取消</el-button><el-button type="primary" :loading="acting" @click="finish">确认结果并完成</el-button></template></el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed,onMounted,reactive,ref } from 'vue';import { useRouter } from 'vue-router';import { useStore } from 'vuex';import { ElMessage } from 'element-plus';import { acknowledgeAlert,closeAlert,getAlert,listAlerts } from '../../../api/alert';const router=useRouter();const store=useStore();const items=ref([]);const loading=ref(false);const acting=ref(false);const next=ref('');const drawer=ref(false);const detail=ref(null);const actionError=ref('');const closeDialog=ref(false);const closeFormRef=ref();const closeForm=reactive({outcome:'',note:''});const closeRules={outcome:[{required:true,message:'请选择现场结果',trigger:'change'}]};const canHandle=computed(()=>store.getters['identity/has']('alerts:handle'));const currentUser=computed(()=>store.state.identity.user);const canClose=computed(()=>detail.value?.alert.status==='acknowledged'&&(detail.value.alert.acknowledged_by===currentUser.value?.id||currentUser.value?.roles?.includes('administrator')));const statusName=value=>({open:'待处理',acknowledged:'处理中',closed:'已完成'}[value]||value);const statusType=value=>({open:'danger',acknowledged:'warning',closed:'success'}[value]||'info');const outcomeName=value=>({danger_confirmed:'确认有危险',false_positive:'误报',site_normal:'现场正常',unable_to_confirm:'无法确认'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listAlerts({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取预警失败')}finally{loading.value=false}}async function open(row){try{detail.value=await getAlert(row.id);actionError.value='';drawer.value=true}catch(e){ElMessage.error(e.error||'读取预警失败')}}async function ack(){acting.value=true;actionError.value='';try{const result=await acknowledgeAlert(detail.value.alert.id);detail.value=result.detail;ElMessage.success(result.idempotent?'您已在处理此预警':'已记录由您开始处理');await load()}catch(e){if(e.current)detail.value=e.current;actionError.value=e.error||'确认失败'}finally{acting.value=false}}async function finish(){try{await closeFormRef.value.validate();acting.value=true;actionError.value='';const result=await closeAlert(detail.value.alert.id,closeForm);detail.value=result.detail;closeDialog.value=false;ElMessage.success(result.idempotent?'该结果已记录':'预警已完成');await load()}catch(e){if(e?.current)detail.value=e.current;if(e?.error)actionError.value=e.error}finally{acting.value=false}}function resetClose(){closeForm.outcome='';closeForm.note='';closeFormRef.value?.clearValidate()}onMounted(()=>load())
|
||||
</script>
|
||||
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}.action-bar{display:flex;gap:12px;margin-top:16px}h2{font-size:16px;margin-top:20px}.outcome-group{display:grid;gap:10px}</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>事件查询</h1><p>原始事件只读保存;处理结果不会改写事件。</p></div></header><el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open"><el-table-column prop="occurred_at" label="发生时间" min-width="180"/><el-table-column prop="event_type" label="事件类型" min-width="140"/><el-table-column prop="location" label="地点" min-width="150"/><el-table-column label="风险"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{severityName(scope.row.severity)}}</el-tag></template></el-table-column><el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无事件"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div><el-drawer v-model="drawer" title="事件技术详情" size="min(560px, 100%)"><template v-if="selected"><el-descriptions :column="1" border><el-descriptions-item label="事件编号">{{selected.id}}</el-descriptions-item><el-descriptions-item label="来源">{{selected.producer_id}}</el-descriptions-item><el-descriptions-item label="来源事件编号">{{selected.source_event_id}}</el-descriptions-item><el-descriptions-item label="发生时间">{{selected.occurred_at}}</el-descriptions-item><el-descriptions-item label="证据引用">{{selected.evidence_ref||'无'}}</el-descriptions-item><el-descriptions-item label="规范化载荷"><pre>{{JSON.stringify(selected.normalized_payload,null,2)}}</pre></el-descriptions-item></el-descriptions><h2>关联预警</h2><el-table :data="selectedAlerts" row-key="id"><el-table-column prop="summary" label="事项"/><el-table-column label="操作" width="80"><template #default><el-button link @click="router.push('/alerts')">查看</el-button></template></el-table-column></el-table><el-empty v-if="!selectedAlerts.length" description="该事件未命中规则"/></template></el-drawer></main></template>
|
||||
<script setup>
|
||||
import { onMounted,ref } from 'vue';import { useRouter } from 'vue-router';import { ElMessage } from 'element-plus';import { alertsForEvent } from '../../api/alert';import { getEvent,listEvents } from '../../api/event';const router=useRouter();const items=ref([]);const loading=ref(false);const next=ref('');const drawer=ref(false);const selected=ref(null);const selectedAlerts=ref([]);const severityType=value=>({critical:'danger',high:'warning',medium:'primary',low:'info'}[value]||'info');const severityName=value=>({critical:'紧急',high:'高',medium:'中',low:'低'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listEvents({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取事件失败')}finally{loading.value=false}}async function open(row){try{selected.value=await getEvent(row.id);selectedAlerts.value=(await alertsForEvent(row.id)).items;drawer.value=true}catch(e){ElMessage.error(e.error||'读取事件失败')}}onMounted(()=>load())
|
||||
</script>
|
||||
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}pre{white-space:pre-wrap;word-break:break-word;margin:0}h2{font-size:16px;margin-top:20px}</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<el-card class="login-card">
|
||||
<template #header><div><p class="eyebrow">BELL</p><h1>登录预警中心</h1><p>使用 Bell 独立账户。Sense 账户不能登录此系统。</p></div></template>
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="用户名" prop="username"><el-input v-model.trim="form.username" autocomplete="username" autofocus /></el-form-item>
|
||||
<el-form-item label="密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" class="full-button">登录 Bell</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</main>
|
||||
</template>
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useStore } from 'vuex'
|
||||
const store=useStore();const route=useRoute();const router=useRouter();const formRef=ref();const loading=ref(false);const error=ref('');const form=reactive({username:'',password:''});const rules={username:[{required:true,message:'请输入用户名',trigger:'blur'}],password:[{required:true,message:'请输入密码',trigger:'blur'}]}
|
||||
async function submit(){if(loading.value)return;try{await formRef.value.validate();loading.value=true;error.value='';await store.dispatch('identity/login',form);await router.replace(String(route.query.redirect||'/'))}catch(e){if(e?.error)error.value=e.error}finally{loading.value=false}}
|
||||
</script>
|
||||
<style scoped>.login-page{min-height:100vh;display:grid;place-items:center;padding:20px;background:#f3f4f7}.login-card{width:min(420px,100%)}h1{margin:0 0 8px}.full-button{width:100%;min-height:44px}</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>规则配置</h1><p>启用规则会评估新 Event;版本与实际匹配快照永久保留。</p></div><el-button v-if="canWrite" type="primary" @click="dialog=true">新建规则</el-button></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="name" label="规则名称" min-width="160"/><el-table-column prop="event_type" label="事件类型"><template #default="scope">{{scope.row.event_type||'全部'}}</template></el-table-column><el-table-column prop="minimum_severity" label="最低风险"/><el-table-column prop="location_contains" label="地点包含"><template #default="scope">{{scope.row.location_contains||'不限'}}</template></el-table-column><el-table-column prop="version" label="版本" width="80"/><el-table-column label="启用" width="100"><template #default="scope"><el-switch :model-value="scope.row.enabled" :disabled="!canWrite" :aria-label="`${scope.row.name}启用状态`" @change="value=>toggle(scope.row,value)"/></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无规则"/><el-dialog v-model="dialog" title="新建预警规则" width="min(520px, calc(100vw - 32px))" @closed="reset"><el-form ref="formRef" :model="form" :rules="rules" label-position="top"><el-form-item label="规则编码" prop="code"><el-input v-model.trim="form.code" placeholder="如 north-wall-danger"/></el-form-item><el-form-item label="规则名称" prop="name"><el-input v-model.trim="form.name"/></el-form-item><el-form-item label="事件类型(留空表示全部)"><el-input v-model.trim="form.event_type"/></el-form-item><el-form-item label="最低风险" prop="minimum_severity"><el-select v-model="form.minimum_severity" style="width:100%"><el-option label="低" value="low"/><el-option label="中" value="medium"/><el-option label="高" value="high"/><el-option label="紧急" value="critical"/></el-select></el-form-item><el-form-item label="地点包含(可选)"><el-input v-model.trim="form.location_contains"/></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建规则</el-button></template></el-dialog></main></template>
|
||||
<script setup>
|
||||
import { computed,onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { useStore } from 'vuex';import { createRule,listRules,setRuleEnabled } from '../../api/rule';const store=useStore();const canWrite=computed(()=>store.getters['identity/has']('rules:write'));const items=ref([]);const loading=ref(false);const saving=ref(false);const dialog=ref(false);const formRef=ref();const form=reactive({code:'',name:'',event_type:'',minimum_severity:'high',location_contains:''});const rules={code:[{required:true,message:'请输入规则编码',trigger:'blur'}],name:[{required:true,message:'请输入规则名称',trigger:'blur'}],minimum_severity:[{required:true,message:'请选择最低风险',trigger:'change'}]};async function load(){loading.value=true;try{items.value=(await listRules()).items}catch(e){ElMessage.error(e.error||'读取规则失败')}finally{loading.value=false}}async function save(){try{await formRef.value.validate();saving.value=true;const payload={...form,event_type:form.event_type||null,location_contains:form.location_contains||null};await createRule(payload);dialog.value=false;ElMessage.success('规则已创建');await load()}catch(e){if(e?.error)ElMessage.error(e.error)}finally{saving.value=false}}async function toggle(item,value){try{await setRuleEnabled(item.id,value);ElMessage.success(value?'规则已启用':'规则已停用');await load()}catch(e){ElMessage.error(e.error||'更新失败')}}function reset(){Object.assign(form,{code:'',name:'',event_type:'',minimum_severity:'high',location_contains:''});formRef.value?.clearValidate()}onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>合成事件测试</h1><p>仅开发测试环境可用;所有成功数据仍经过正式 Event/Receipt 服务。</p></div><el-tag type="warning">开发测试</el-tag></header><el-card><el-alert v-if="error" :title="error" type="error" show-icon :closable="false"/><el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit"><el-form-item label="测试场景" prop="fixture"><el-select v-model="form.fixture" style="width:100%"><el-option v-for="item in fixtures" :key="item.name" :label="item.label" :value="item.name"><span>{{item.label}}</span><el-tag v-if="item.expected_failure" type="info" size="small" style="margin-left:8px">预期失败</el-tag></el-option></el-select></el-form-item><el-form-item label="来源事件编号" prop="source_event_id"><el-input v-model.trim="form.source_event_id"/><small>保持编号不变可验证重复;换用另一夹具可验证冲突。</small></el-form-item><el-button type="primary" native-type="submit" :loading="loading">注入合成事件</el-button></el-form></el-card><el-card v-if="result" style="margin-top:16px"><template #header>接收结果</template><el-descriptions :column="1" border><el-descriptions-item label="结果">{{result.duplicate?'重复请求,返回原收据':'首次接收'}}</el-descriptions-item><el-descriptions-item label="Event ID">{{result.event.id}}</el-descriptions-item><el-descriptions-item label="Receipt ID">{{result.receipt.id}}</el-descriptions-item></el-descriptions></el-card></main></template>
|
||||
<script setup>
|
||||
import { onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { injectSynthetic,listFixtures } from '../../api/synthetic';const fixtures=ref([]);const formRef=ref();const loading=ref(false);const error=ref('');const result=ref(null);const form=reactive({fixture:'danger-zone',source_event_id:`ui-${Date.now()}`});const rules={fixture:[{required:true,message:'请选择场景',trigger:'change'}],source_event_id:[{required:true,message:'请输入来源事件编号',trigger:'blur'}]};onMounted(async()=>{try{fixtures.value=(await listFixtures()).items}catch(e){error.value=e.error||'测试入口不可用'}});async function submit(){if(loading.value)return;try{await formRef.value.validate();loading.value=true;error.value='';result.value=await injectSynthetic(form);ElMessage.success(result.value.duplicate?'已返回原收据':'合成事件已接收')}catch(e){if(e?.error)error.value=e.error}finally{loading.value=false}}
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>认证审计</h1><p>登录、退出和权限拒绝事实仅追加保存。</p></div></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="occurred_at" label="时间" min-width="180"/><el-table-column prop="action" label="动作" min-width="150"/><el-table-column prop="outcome" label="结果"><template #default="scope"><el-tag :type="scope.row.outcome==='success'?'success':'danger'">{{scope.row.outcome}}</el-tag></template></el-table-column><el-table-column prop="target_type" label="对象"/></el-table></main></template>
|
||||
<script setup>
|
||||
import { onMounted,ref } from 'vue';import { ElMessage } from 'element-plus';import { listAudit } from '../../api/auth';const items=ref([]);const loading=ref(false);onMounted(async()=>{loading.value=true;try{items.value=(await listAudit()).items}catch(e){ElMessage.error(e.error||'读取审计失败')}finally{loading.value=false}})
|
||||
</script>
|
||||
@@ -0,0 +1,4 @@
|
||||
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>用户与角色</h1><p>Bell 账户与 Sense 完全独立;按最小权限分配角色。</p></div><el-button v-if="canWrite" type="primary" @click="dialog=true">创建用户</el-button></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="username" label="用户名"/><el-table-column prop="display_name" label="显示名称"/><el-table-column label="角色"><template #default="scope"><el-select v-if="canWrite" :model-value="scope.row.roles[0]" aria-label="角色" @change="role=>changeRole(scope.row,role)"><el-option v-for="option in roleOptions" :key="option.value" v-bind="option"/></el-select><span v-else>{{roleName(scope.row.roles[0])}}</span></template></el-table-column><el-table-column label="状态"><template #default="scope"><el-tag :type="scope.row.enabled?'success':'info'">{{scope.row.enabled?'启用':'停用'}}</el-tag></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚未创建用户"/><el-dialog v-model="dialog" title="创建 Bell 用户" width="min(480px, calc(100vw - 32px))" @closed="reset"><el-alert title="不会生成默认密码;密码必须由管理员安全传递。" type="info" :closable="false"/><el-form ref="formRef" :model="form" :rules="rules" label-position="top"><el-form-item label="用户名" prop="username"><el-input v-model.trim="form.username"/></el-form-item><el-form-item label="显示名称" prop="display_name"><el-input v-model.trim="form.display_name"/></el-form-item><el-form-item label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="new-password"/></el-form-item><el-form-item label="角色" prop="role"><el-select v-model="form.role" style="width:100%"><el-option v-for="option in roleOptions" :key="option.value" v-bind="option"/></el-select></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建用户</el-button></template></el-dialog></main></template>
|
||||
<script setup>
|
||||
import { computed,onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { useStore } from 'vuex';import { createUser,listUsers,replaceRoles } from '../../api/auth';const store=useStore();const items=ref([]);const loading=ref(false);const dialog=ref(false);const saving=ref(false);const formRef=ref();const form=reactive({username:'',display_name:'',password:'',role:'operator'});const canWrite=computed(()=>store.getters['identity/has']('users:write'));const roleOptions=[{value:'administrator',label:'管理员'},{value:'operator',label:'处置员'},{value:'viewer',label:'只读用户'}];const rules={username:[{required:true,message:'请输入用户名',trigger:'blur'}],display_name:[{required:true,message:'请输入显示名称',trigger:'blur'}],password:[{required:true,message:'请输入初始密码',trigger:'blur'},{min:12,message:'至少 12 个字符',trigger:'blur'}],role:[{required:true,message:'请选择角色',trigger:'change'}]};const roleName=value=>roleOptions.find(x=>x.value===value)?.label||value;async function load(){loading.value=true;try{items.value=(await listUsers()).items}catch(e){ElMessage.error(e.error||'读取用户失败')}finally{loading.value=false}}async function save(){try{await formRef.value.validate();saving.value=true;await createUser(form);ElMessage.success('用户已创建');dialog.value=false;await load()}catch(e){if(e?.error)ElMessage.error(e.error)}finally{saving.value=false}}async function changeRole(user,role){try{await replaceRoles(user.id,[role]);ElMessage.success('角色已更新');await load()}catch(e){ElMessage.error(e.error||'角色更新失败')}}function reset(){form.username='';form.display_name='';form.password='';form.role='operator';formRef.value?.clearValidate()}onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
outputDir: 'dist',
|
||||
devServer: {
|
||||
port: 8083,
|
||||
proxy: { '/api': { target: process.env.BELL_API_ORIGIN || 'http://127.0.0.1:8082' } }
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user