feat: add independent Bell authentication and RBAC (#12)

This commit is contained in:
QiuSW
2026-08-12 17:44:31 +08:00
parent a5d846a437
commit a5fc8f5490
28 changed files with 840 additions and 22 deletions
@@ -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';
+56
View File
@@ -0,0 +1,56 @@
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 {
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(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 := pool.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 := pool.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
}