BELL
登录预警中心
使用 Bell 独立账户。Sense 账户不能登录此系统。
From a5fc8f5490a9b281643346a85aab949daee5d498 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Wed, 12 Aug 2026 17:44:31 +0800
Subject: [PATCH] feat: add independent Bell authentication and RBAC (#12)
---
Bell/.env.example | 2 +-
Bell/README.md | 12 ++
Bell/server/app/audit/store.go | 56 ++++++
Bell/server/app/auth/http.go | 178 ++++++++++++++++++
Bell/server/app/auth/password.go | 41 ++++
Bell/server/app/auth/password_test.go | 27 +++
Bell/server/app/auth/service.go | 94 +++++++++
Bell/server/app/auth/store.go | 164 ++++++++++++++++
Bell/server/app/auth/types.go | 25 +++
Bell/server/app/rbac/permissions.go | 31 +++
Bell/server/cmd/bell/root.go | 35 +++-
Bell/server/config/config.go | 7 +
Bell/server/go.mod | 6 +-
Bell/server/internal/platform/http.go | 2 +
.../2026081200_platform.sql} | 1 -
Bell/server/migrations/2026081201_auth.sql | 62 ++++++
.../migrations.go => migrations/runner.go} | 14 +-
Bell/web/src/api/auth/index.js | 9 +
Bell/web/src/bootstrap/App.vue | 4 +-
Bell/web/src/bootstrap/request.js | 5 +
Bell/web/src/bootstrap/router.js | 24 ++-
Bell/web/src/bootstrap/store.js | 3 +-
Bell/web/src/bootstrap/theme.css | 2 +-
Bell/web/src/layout/AppLayout.vue | 16 +-
Bell/web/src/store/modules/identity.js | 13 ++
Bell/web/src/views/login/Login.vue | 21 +++
Bell/web/src/views/system/Audit.vue | 4 +
Bell/web/src/views/system/Users.vue | 4 +
28 files changed, 840 insertions(+), 22 deletions(-)
create mode 100644 Bell/server/app/audit/store.go
create mode 100644 Bell/server/app/auth/http.go
create mode 100644 Bell/server/app/auth/password.go
create mode 100644 Bell/server/app/auth/password_test.go
create mode 100644 Bell/server/app/auth/service.go
create mode 100644 Bell/server/app/auth/store.go
create mode 100644 Bell/server/app/auth/types.go
create mode 100644 Bell/server/app/rbac/permissions.go
rename Bell/server/{internal/platform/migrations/0001_platform.sql => migrations/2026081200_platform.sql} (99%)
create mode 100644 Bell/server/migrations/2026081201_auth.sql
rename Bell/server/{internal/platform/migrations.go => migrations/runner.go} (80%)
create mode 100644 Bell/web/src/api/auth/index.js
create mode 100644 Bell/web/src/bootstrap/request.js
create mode 100644 Bell/web/src/store/modules/identity.js
create mode 100644 Bell/web/src/views/login/Login.vue
create mode 100644 Bell/web/src/views/system/Audit.vue
create mode 100644 Bell/web/src/views/system/Users.vue
diff --git a/Bell/.env.example b/Bell/.env.example
index b798e5a..e28d9ca 100644
--- a/Bell/.env.example
+++ b/Bell/.env.example
@@ -2,5 +2,5 @@
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
-
diff --git a/Bell/README.md b/Bell/README.md
index b9b06fc..072ad58 100644
--- a/Bell/README.md
+++ b/Bell/README.md
@@ -16,6 +16,8 @@ Exact upstream sources and retained MIT notices are in `LICENSES/`.
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
@@ -27,6 +29,16 @@ 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
diff --git a/Bell/server/app/audit/store.go b/Bell/server/app/audit/store.go
new file mode 100644
index 0000000..dafbd65
--- /dev/null
+++ b/Bell/server/app/audit/store.go
@@ -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()
+}
diff --git a/Bell/server/app/auth/http.go b/Bell/server/app/auth/http.go
new file mode 100644
index 0000000..17016bb
--- /dev/null
+++ b/Bell/server/app/auth/http.go
@@ -0,0 +1,178 @@
+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 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)
+}
diff --git a/Bell/server/app/auth/password.go b/Bell/server/app/auth/password.go
new file mode 100644
index 0000000..789bc65
--- /dev/null
+++ b/Bell/server/app/auth/password.go
@@ -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
+}
diff --git a/Bell/server/app/auth/password_test.go b/Bell/server/app/auth/password_test.go
new file mode 100644
index 0000000..f07e1f6
--- /dev/null
+++ b/Bell/server/app/auth/password_test.go
@@ -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")
+ }
+}
diff --git a/Bell/server/app/auth/service.go b/Bell/server/app/auth/service.go
new file mode 100644
index 0000000..42fc942
--- /dev/null
+++ b/Bell/server/app/auth/service.go
@@ -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{})
+}
diff --git a/Bell/server/app/auth/store.go b/Bell/server/app/auth/store.go
new file mode 100644
index 0000000..29ed08b
--- /dev/null
+++ b/Bell/server/app/auth/store.go
@@ -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)
+}
diff --git a/Bell/server/app/auth/types.go b/Bell/server/app/auth/types.go
new file mode 100644
index 0000000..0871f4f
--- /dev/null
+++ b/Bell/server/app/auth/types.go
@@ -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{}
diff --git a/Bell/server/app/rbac/permissions.go b/Bell/server/app/rbac/permissions.go
new file mode 100644
index 0000000..22ca365
--- /dev/null
+++ b/Bell/server/app/rbac/permissions.go
@@ -0,0 +1,31 @@
+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"
+)
+
+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)
+}
diff --git a/Bell/server/cmd/bell/root.go b/Bell/server/cmd/bell/root.go
index 659d747..c70038a 100644
--- a/Bell/server/cmd/bell/root.go
+++ b/Bell/server/cmd/bell/root.go
@@ -8,10 +8,15 @@ import (
"net/http"
"os"
"os/signal"
+ "strings"
"syscall"
+ "time"
+ "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/config"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
+ "git.ilapage.cn/ila/yovision/Bell/server/migrations"
)
const Version = "0.1.0"
@@ -25,8 +30,8 @@ func Run(ctx context.Context, args []string) error {
fmt.Println(Version)
return nil
}
- if command != "serve" && command != "migrate" {
- return fmt.Errorf("unknown command %q (use serve, migrate, or version)", command)
+ 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 {
@@ -37,13 +42,28 @@ func Run(ctx context.Context, args []string) error {
return err
}
defer db.Close()
- if err := platform.Migrate(ctx, db); err != nil {
+ 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)
+ auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}.Register(app.Router())
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -59,3 +79,12 @@ func Run(ctx context.Context, args []string) error {
}
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 ""
+}
diff --git a/Bell/server/config/config.go b/Bell/server/config/config.go
index b2a9982..f8f87de 100644
--- a/Bell/server/config/config.go
+++ b/Bell/server/config/config.go
@@ -11,6 +11,8 @@ type Config struct {
Environment string
HTTPAddress string
DatabaseURL string
+ SessionSecret string
+ CookieSecure bool
ShutdownTimeout time.Duration
}
@@ -19,6 +21,7 @@ func Load() (Config, error) {
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 != "" {
@@ -31,6 +34,10 @@ func Load() (Config, error) {
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
}
diff --git a/Bell/server/go.mod b/Bell/server/go.mod
index f135f6f..a836d5a 100644
--- a/Bell/server/go.mod
+++ b/Bell/server/go.mod
@@ -2,13 +2,15 @@ module git.ilapage.cn/ila/yovision/Bell/server
go 1.26.5
-require github.com/jackc/pgx/v5 v5.7.6
+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/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
diff --git a/Bell/server/internal/platform/http.go b/Bell/server/internal/platform/http.go
index 0bc554c..7fee0b6 100644
--- a/Bell/server/internal/platform/http.go
+++ b/Bell/server/internal/platform/http.go
@@ -28,6 +28,8 @@ func NewHTTPApp(db *pgxpool.Pool) *HTTPApp {
return app
}
+func (a *HTTPApp) Router() *http.ServeMux { return a.Mux }
+
func (a *HTTPApp) Handler() http.Handler {
return securityHeaders(a.Mux)
}
diff --git a/Bell/server/internal/platform/migrations/0001_platform.sql b/Bell/server/migrations/2026081200_platform.sql
similarity index 99%
rename from Bell/server/internal/platform/migrations/0001_platform.sql
rename to Bell/server/migrations/2026081200_platform.sql
index 9bfd923..9b06344 100644
--- a/Bell/server/internal/platform/migrations/0001_platform.sql
+++ b/Bell/server/migrations/2026081200_platform.sql
@@ -2,4 +2,3 @@ CREATE TABLE IF NOT EXISTS bell_runtime_probe (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
started_at timestamptz NOT NULL DEFAULT now()
);
-
diff --git a/Bell/server/migrations/2026081201_auth.sql b/Bell/server/migrations/2026081201_auth.sql
new file mode 100644
index 0000000..be47d0e
--- /dev/null
+++ b/Bell/server/migrations/2026081201_auth.sql
@@ -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';
diff --git a/Bell/server/internal/platform/migrations.go b/Bell/server/migrations/runner.go
similarity index 80%
rename from Bell/server/internal/platform/migrations.go
rename to Bell/server/migrations/runner.go
index 842c376..d97c04f 100644
--- a/Bell/server/internal/platform/migrations.go
+++ b/Bell/server/migrations/runner.go
@@ -1,4 +1,4 @@
-package platform
+package migrations
import (
"context"
@@ -10,20 +10,20 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
-//go:embed migrations/*.sql
-var migrationFiles embed.FS
+//go:embed *.sql
+var files embed.FS
-func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
+func Apply(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.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(migrationFiles, "migrations")
+ 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() {
+ if entry.IsDir() || len(entry.Name()) < 4 || entry.Name()[len(entry.Name())-4:] != ".sql" {
continue
}
var applied bool
@@ -33,7 +33,7 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
if applied {
continue
}
- sqlBytes, err := migrationFiles.ReadFile("migrations/" + entry.Name())
+ sqlBytes, err := files.ReadFile(entry.Name())
if err != nil {
return err
}
diff --git a/Bell/web/src/api/auth/index.js b/Bell/web/src/api/auth/index.js
new file mode 100644
index 0000000..a207421
--- /dev/null
+++ b/Bell/web/src/api/auth/index.js
@@ -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')
diff --git a/Bell/web/src/bootstrap/App.vue b/Bell/web/src/bootstrap/App.vue
index 1f43a84..f4983b1 100644
--- a/Bell/web/src/bootstrap/App.vue
+++ b/Bell/web/src/bootstrap/App.vue
@@ -1,4 +1,6 @@
-
BELL
使用 Bell 独立账户。Sense 账户不能登录此系统。
登录、退出和权限拒绝事实仅追加保存。
Bell 账户与 Sense 完全独立;按最小权限分配角色。