This commit is contained in:
Marsway 2026-05-14 16:50:06 +08:00
parent adb93ae6cc
commit 7050c80632
11 changed files with 569 additions and 26 deletions

View File

@ -113,6 +113,25 @@ ConnectHub 是一个轻量级企业集成中间件:统一管理多系统集成
- Worker 执行前自动解密,仅在内存中传给 Job
- key 自动生成到 `FERNET_KEY_PATH`(默认 `/data/fernet.key`volume 挂载后可持久化
#### 账户、权限与 LDAP
- 本地原始 `admin``BOOTSTRAP_ADMIN_*` 配置自动创建,是 LDAP 不可用时的兜底管理员。
- 后台菜单由 SQLAdmin ModelView 生成,并通过权限码控制可见性:
- `table:{table}:read`:是否显示菜单和详情页
- `table:{table}:write`:是否允许新增、编辑、删除
- `button:{code}`:是否允许执行按钮动作
- 启动时会自动创建两个标准角色:
- `system_admin`系统管理员角色拥有任务、账户、LDAP、权限和审计管理能力。
- `readonly_viewer`:只读查看者角色,仅可查看任务和任务日志。
- LDAP 连接信息优先在后台 `LDAP配置` 菜单中维护。首次启动会参考 `.env` 中的 LDAP 变量创建默认配置,之后运行时以数据库配置为准。
- `LDAP配置` 中的服务账号密码不会回显,留空保存表示不修改,填写新密码会使用 Fernet 加密落库。
- 将 AD 安全组映射为系统角色的流程:
1. 使用本地 `admin` 登录后台。
2. 进入 `LDAP配置`,填写 LDAP 地址、服务账号、Base DN、用户过滤器和组过滤器并点击 `测试连接`
3. 让目标 AD 用户登录一次,或在 `LDAP组` 中执行同步,使系统记录对应安全组。
4. 在 `LDAP组映射` 中把管理员安全组映射到 `system_admin`,把只读安全组映射到 `readonly_viewer`
5. 映射后 LDAP 用户再次登录会刷新角色;未命中任何映射角色的 LDAP 用户不能进入系统。
#### PluginManager动态加载
- 位置:`app/plugins/manager.py`

View File

@ -0,0 +1,43 @@
{% extends "sqladmin/edit.html" %}
{% block content %}
{{ super() }}
<div class="card mt-3">
<div class="card-body">
<div class="row mb-3">
<label class="form-label col-sm-2 col-form-label">服务账号密码</label>
<div class="col-sm-10">
<input id="connecthub-bind-password" class="form-control" type="password" autocomplete="new-password" placeholder="留空表示不修改;填写后将加密保存。">
<div class="form-text">
出于安全考虑,编辑页不回显历史密码。留空表示不修改;填写新密码将覆盖旧密码并加密落库。
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block tail %}
{{ super() }}
<script>
(function () {
// SQLAdmin 默认 edit 页面会渲染一个 form这里将密码输入注入为隐藏字段以便提交到后端。
const form = document.querySelector("form");
const password = document.getElementById("connecthub-bind-password");
if (!form || !password) return;
let hidden = form.querySelector('input[name="bind_password"]');
if (!hidden) {
hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "bind_password";
form.appendChild(hidden);
}
form.addEventListener("submit", function () {
hidden.value = password.value || "";
});
})();
</script>
{% endblock %}

View File

@ -17,15 +17,18 @@ from app.db import crud
from app.db.engine import get_session
from app.db.models import JobStatus
from app.db.models import Job, JobLog
from app.db.models import AuditLog, LdapGroup, LdapGroupRole, Permission, Role, User
from app.db.models import AuditLog, LdapConfig, LdapGroup, LdapGroupRole, Permission, Role, User
from app.plugins.manager import load_job_class
from app.security.fernet import encrypt_json
from app.tasks.execute import execute_job
from app.admin.secure import SecureModelView
from app.security.permissions import button_permission_code
from app.security.audit import log_event
from app.security.ldap_client import LdapClient
from app.security.ldap_config import encrypt_bind_password, mask_sensitive_result, to_runtime_config
from app.security.ldap_sync import sync_all_ldap_users
from app.security.auth import hash_password
from app.security.session import get_current_user
def _maybe_json(value: Any) -> Any:
@ -533,6 +536,144 @@ class PermissionAdmin(SecureModelView, model=Permission):
session.close()
class LdapConfigAdmin(SecureModelView, model=LdapConfig):
name = "LDAP配置"
name_plural = "LDAP配置"
icon = "fa fa-sliders"
can_create = False
can_delete = False
edit_template = "ldap_config_edit.html"
column_list = [LdapConfig.enabled, LdapConfig.uri, LdapConfig.bind_dn, LdapConfig.base_dn, LdapConfig.last_test_at]
column_details_list = [
LdapConfig.enabled,
LdapConfig.uri,
LdapConfig.bind_dn,
LdapConfig.bind_password_encrypted,
LdapConfig.base_dn,
LdapConfig.user_filter,
LdapConfig.group_filter,
LdapConfig.use_starttls,
LdapConfig.verify_tls,
LdapConfig.last_test_at,
LdapConfig.last_test_result,
LdapConfig.updated_by_user_id,
LdapConfig.created_at,
LdapConfig.updated_at,
]
form_excluded_columns = [
LdapConfig.name,
LdapConfig.bind_password_encrypted,
LdapConfig.last_test_at,
LdapConfig.last_test_result,
LdapConfig.updated_by_user_id,
LdapConfig.created_at,
LdapConfig.updated_at,
]
column_labels = {
"name": "配置名称",
"enabled": "启用LDAP登录",
"uri": "LDAP地址",
"bind_dn": "服务账号DN",
"bind_password_encrypted": "服务账号密码",
"base_dn": "Base DN",
"user_filter": "用户过滤器",
"group_filter": "组过滤器",
"use_starttls": "启用StartTLS",
"verify_tls": "校验证书",
"last_test_at": "上次测试时间",
"last_test_result": "上次测试结果",
"updated_by_user_id": "更新人ID",
"created_at": "创建时间",
"updated_at": "更新时间",
}
column_formatters = {
LdapConfig.last_test_at: lambda m, a: _fmt_dt_seconds(m.last_test_at),
}
column_formatters_detail = {
LdapConfig.bind_password_encrypted: lambda m, a: "******" if m.bind_password_encrypted else "",
LdapConfig.last_test_at: lambda m, a: _fmt_dt_seconds(m.last_test_at),
LdapConfig.last_test_result: lambda m, a: Markup(
"<pre style='white-space:pre-wrap'>"
+ json.dumps(m.last_test_result or {}, ensure_ascii=False, indent=2, sort_keys=True)
+ "</pre>"
),
LdapConfig.created_at: lambda m, a: _fmt_dt_seconds(m.created_at),
LdapConfig.updated_at: lambda m, a: _fmt_dt_seconds(m.updated_at),
}
@action(
name="test_connection",
label="测试连接",
confirmation_message="确认使用当前保存的 LDAP 配置测试连接?",
add_in_list=True,
add_in_detail=True,
)
async def test_connection(self, request: Request): # type: ignore[override]
if not self.has_action_permission(request, "test_connection"):
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
pks = [pk for pk in request.query_params.get("pks", "").split(",") if pk]
pk = int(pks[0]) if pks else None
session = get_session()
try:
config = session.get(LdapConfig, pk) if pk is not None else session.query(LdapConfig).first()
if not config:
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=303)
result = LdapClient(to_runtime_config(config)).test_connection()
config.last_test_at = datetime.utcnow()
config.last_test_result = mask_sensitive_result(result)
session.add(config)
session.commit()
log_event(
session,
action="ldap.test",
target=f"ldap_config:{config.id}",
detail=config.last_test_result,
request=request,
)
return RedirectResponse(request.url_for("admin:details", identity=self.identity, pk=str(config.id)), status_code=303)
finally:
session.close()
def get_action_permission_code(self, action_name: str) -> str | None:
if action_name == "test_connection":
return button_permission_code("ldap:test")
return None
async def on_model_change(self, data: dict, model: LdapConfig, is_created: bool, request) -> None: # type: ignore[override]
if not data.get("name"):
data["name"] = "default"
model.name = "default"
try:
form = await request.form()
raw_password = str(form.get("bind_password") or "").strip()
except Exception:
raw_password = ""
if raw_password:
encrypted = encrypt_bind_password(raw_password)
data["bind_password_encrypted"] = encrypted
model.bind_password_encrypted = encrypted
user = get_current_user(request)
if user:
data["updated_by_user_id"] = user.id
model.updated_by_user_id = user.id
session = get_session()
try:
log_event(
session,
action="ldap.config.change",
target=f"ldap_config:{model.name or 'default'}",
detail={"created": is_created},
request=request,
)
finally:
session.close()
class LdapGroupAdmin(SecureModelView, model=LdapGroup):
name = "LDAP组"
name_plural = "LDAP组"
@ -572,6 +713,12 @@ class LdapGroupRoleAdmin(SecureModelView, model=LdapGroupRole):
name = "LDAP组映射"
name_plural = "LDAP组映射"
icon = "fa fa-random"
column_list = [LdapGroupRole.id, LdapGroupRole.ldap_group, LdapGroupRole.role]
form_columns = [LdapGroupRole.ldap_group, LdapGroupRole.role]
column_labels = {
"ldap_group": "LDAP组",
"role": "系统角色",
}
async def on_model_change(self, data: dict, model: LdapGroupRole, is_created: bool, request) -> None: # type: ignore[override]
session = get_session()

View File

@ -107,6 +107,9 @@ class Role(Base):
users: Mapped[list[User]] = relationship(secondary=user_roles, back_populates="roles")
permissions: Mapped[list["Permission"]] = relationship(secondary=role_permissions, back_populates="roles")
def __repr__(self) -> str:
return self.name
class Permission(Base):
__tablename__ = "permissions"
@ -125,6 +128,9 @@ class LdapGroup(Base):
dn: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
def __repr__(self) -> str:
return self.name or self.dn
class LdapGroupRole(Base):
__tablename__ = "ldap_group_roles"
@ -133,6 +139,32 @@ class LdapGroupRole(Base):
ldap_group_id: Mapped[int] = mapped_column(ForeignKey("ldap_groups.id"), nullable=False)
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False)
ldap_group: Mapped[LdapGroup] = relationship()
role: Mapped[Role] = relationship()
class LdapConfig(Base):
__tablename__ = "ldap_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, unique=True, index=True, default="default", nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
uri: Mapped[str] = mapped_column(String, default="", nullable=False)
bind_dn: Mapped[str] = mapped_column(String, default="", nullable=False)
bind_password_encrypted: Mapped[str] = mapped_column(Text, default="", nullable=False)
base_dn: Mapped[str] = mapped_column(String, default="", nullable=False)
user_filter: Mapped[str] = mapped_column(String, default="(uid={username})", nullable=False)
group_filter: Mapped[str] = mapped_column(String, default="(member={user_dn})", nullable=False)
use_starttls: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
verify_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_test_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_test_result: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class AuditLog(Base):
__tablename__ = "audit_logs"

View File

@ -11,6 +11,7 @@ from app.admin.views import (
AuditLogAdmin,
JobAdmin,
JobLogAdmin,
LdapConfigAdmin,
LdapGroupAdmin,
LdapGroupRoleAdmin,
PermissionAdmin,
@ -25,6 +26,8 @@ from app.security.bootstrap import bootstrap_admin
from app.security.session import get_current_user
from app.security.fernet import get_or_create_fernet_key
from app.api.auth_routes import router as auth_router
from app.security.ldap_config import ensure_default_ldap_config
from app.security.rbac_seed import seed_standard_rbac
def _init_db() -> None:
@ -40,6 +43,8 @@ def _ensure_runtime() -> None:
get_or_create_fernet_key(settings.fernet_key_path)
_init_db()
bootstrap_admin()
ensure_default_ldap_config()
seed_standard_rbac()
def create_app() -> FastAPI:
@ -57,6 +62,7 @@ def create_app() -> FastAPI:
admin.add_view(UserAdmin)
admin.add_view(RoleAdmin)
admin.add_view(PermissionAdmin)
admin.add_view(LdapConfigAdmin)
admin.add_view(LdapGroupAdmin)
admin.add_view(LdapGroupRoleAdmin)
admin.add_view(AuditLogAdmin)

View File

@ -51,7 +51,23 @@ def authenticate_local(username: str, password: str, request=None) -> int | None
def authenticate_ldap(username: str, password: str, request=None) -> int | None:
client = LdapClient()
result = client.authenticate(username, password)
if not client.is_enabled():
return None
try:
result = client.authenticate(username, password)
except Exception as exc:
db = get_session()
try:
log_event(
db,
action="login.failed",
target=username,
detail={"reason": "ldap_error", "message": str(exc)},
request=request,
)
finally:
db.close()
return None
if not result:
db = get_session()
try:
@ -72,7 +88,19 @@ def authenticate_ldap(username: str, password: str, request=None) -> int | None:
user.is_ldap = True
user.is_active = True
user.last_login_at = datetime.utcnow()
sync_user_ldap_roles(session=db, user=user, username=username, user_dn=user_dn)
roles = sync_user_ldap_roles(session=db, user=user, username=username, user_dn=user_dn)
if not roles:
db.add(user)
db.commit()
log_event(
db,
action="login.failed",
target=username,
detail={"reason": "ldap.no_mapped_role", "user_dn": user_dn},
request=request,
actor=user,
)
return None
db.add(user)
db.commit()
log_event(db, action="login.success", target=username, detail={"provider": "ldap"}, request=request, actor=user)

View File

@ -5,34 +5,53 @@ from typing import Any
from ldap3 import ALL, Connection, Server, Tls
from app.core.config import settings
from app.security.ldap_config import RuntimeLdapConfig, get_runtime_ldap_config
class LdapClient:
def __init__(self) -> None:
def __init__(self, config: RuntimeLdapConfig | None = None) -> None:
self.config = config if config is not None else get_runtime_ldap_config()
self._server: Server | None = None
if not self.config:
return
tls = None
if settings.ldap_uri.startswith("ldaps://") or settings.ldap_use_starttls:
tls = Tls(validate=ssl.CERT_REQUIRED if settings.ldap_verify_tls else ssl.CERT_NONE)
self._server = Server(settings.ldap_uri, use_ssl=settings.ldap_uri.startswith("ldaps://"), get_info=ALL, tls=tls)
if self.config.uri.startswith("ldaps://") or self.config.use_starttls:
tls = Tls(validate=ssl.CERT_REQUIRED if self.config.verify_tls else ssl.CERT_NONE)
self._server = Server(self.config.uri, use_ssl=self.config.uri.startswith("ldaps://"), get_info=ALL, tls=tls)
def is_enabled(self) -> bool:
return bool(self.config and self.config.enabled and self.config.uri and self.config.base_dn)
def _format_filter(self, template: str, **values: str) -> str:
# 兼容 AD 配置中常见的 sAMAccountName 占位写法。
if "username" in values:
values.setdefault("sAMAccountName", values["username"])
return template.format(**values)
def _connect(self, *, bind_dn: str | None = None, password: str | None = None) -> Connection:
if not self._server:
raise RuntimeError("LDAP is not configured")
conn = Connection(self._server, user=bind_dn, password=password, auto_bind=False)
conn.open()
if settings.ldap_use_starttls and self._server.ssl is False:
conn.start_tls()
conn.bind()
if self.config and self.config.use_starttls and self._server.ssl is False:
if not conn.start_tls():
raise RuntimeError(f"LDAP StartTLS failed: {conn.result}")
if not conn.bind():
raise RuntimeError(f"LDAP bind failed: {conn.result}")
return conn
def _service_conn(self) -> Connection:
return self._connect(bind_dn=settings.ldap_bind_dn, password=settings.ldap_bind_password)
if not self.config:
raise RuntimeError("LDAP is not configured")
return self._connect(bind_dn=self.config.bind_dn, password=self.config.bind_password)
def find_user_dn(self, username: str) -> str | None:
if not settings.ldap_base_dn:
if not self.is_enabled() or not self.config:
return None
conn = self._service_conn()
try:
search_filter = settings.ldap_user_filter.format(username=username)
conn.search(settings.ldap_base_dn, search_filter, attributes=["dn"])
search_filter = self._format_filter(self.config.user_filter, username=username)
conn.search(self.config.base_dn, search_filter, attributes=["dn"])
if not conn.entries:
return None
return conn.entries[0].entry_dn
@ -51,12 +70,12 @@ class LdapClient:
return None
def get_user_groups(self, *, user_dn: str, username: str) -> list[dict[str, str]]:
if not settings.ldap_base_dn:
if not self.is_enabled() or not self.config:
return []
conn = self._service_conn()
try:
search_filter = settings.ldap_group_filter.format(user_dn=user_dn, username=username)
conn.search(settings.ldap_base_dn, search_filter, attributes=["cn"])
search_filter = self._format_filter(self.config.group_filter, user_dn=user_dn, username=username)
conn.search(self.config.base_dn, search_filter, attributes=["cn"])
results: list[dict[str, str]] = []
for entry in conn.entries:
dn = entry.entry_dn
@ -69,3 +88,21 @@ class LdapClient:
return results
finally:
conn.unbind()
def test_connection(self) -> dict[str, Any]:
if not self.is_enabled() or not self.config:
return {"ok": False, "message": "LDAP 未启用或配置不完整"}
conn = None
try:
conn = self._service_conn()
conn.search(self.config.base_dn, "(objectClass=*)", attributes=["dn"], size_limit=1)
return {
"ok": True,
"message": "LDAP 服务账号连接成功",
"entries_found": len(conn.entries),
}
except Exception as exc:
return {"ok": False, "message": str(exc)}
finally:
if conn is not None:
conn.unbind()

104
app/security/ldap_config.py Normal file
View File

@ -0,0 +1,104 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.engine import get_session
from app.db.models import LdapConfig
from app.security.fernet import decrypt_json, encrypt_json
LDAP_CONFIG_NAME = "default"
@dataclass(frozen=True)
class RuntimeLdapConfig:
enabled: bool
uri: str
bind_dn: str
bind_password: str
base_dn: str
user_filter: str
group_filter: str
use_starttls: bool
verify_tls: bool
def encrypt_bind_password(password: str) -> str:
if not password:
return ""
return encrypt_json({"password": password})
def decrypt_bind_password(token: str) -> str:
if not token:
return ""
try:
data = decrypt_json(token)
except Exception:
return ""
value = data.get("password")
return str(value) if value is not None else ""
def get_default_ldap_config(session: Session) -> LdapConfig | None:
return session.scalar(select(LdapConfig).where(LdapConfig.name == LDAP_CONFIG_NAME))
def ensure_default_ldap_config() -> None:
db = get_session()
try:
if get_default_ldap_config(db):
return
config = LdapConfig(
name=LDAP_CONFIG_NAME,
enabled=bool(settings.ldap_base_dn),
uri=settings.ldap_uri,
bind_dn=settings.ldap_bind_dn,
bind_password_encrypted=encrypt_bind_password(settings.ldap_bind_password),
base_dn=settings.ldap_base_dn,
user_filter=settings.ldap_user_filter,
group_filter=settings.ldap_group_filter,
use_starttls=settings.ldap_use_starttls,
verify_tls=settings.ldap_verify_tls,
last_test_result={},
)
db.add(config)
db.commit()
finally:
db.close()
def to_runtime_config(config: LdapConfig | None) -> RuntimeLdapConfig | None:
if not config or not config.enabled:
return None
return RuntimeLdapConfig(
enabled=bool(config.enabled),
uri=config.uri,
bind_dn=config.bind_dn,
bind_password=decrypt_bind_password(config.bind_password_encrypted),
base_dn=config.base_dn,
user_filter=config.user_filter,
group_filter=config.group_filter,
use_starttls=bool(config.use_starttls),
verify_tls=bool(config.verify_tls),
)
def get_runtime_ldap_config() -> RuntimeLdapConfig | None:
db = get_session()
try:
return to_runtime_config(get_default_ldap_config(db))
finally:
db.close()
def mask_sensitive_result(result: dict[str, Any]) -> dict[str, Any]:
cleaned = dict(result)
cleaned.pop("password", None)
cleaned.pop("bind_password", None)
return cleaned

View File

@ -27,14 +27,14 @@ def _ensure_groups(session, groups: list[dict[str, str]]) -> list[LdapGroup]:
return results
def sync_user_ldap_roles(*, session, user: User, username: str, user_dn: str) -> None:
def sync_user_ldap_roles(*, session, user: User, username: str, user_dn: str) -> list[Role]:
client = LdapClient()
groups = client.get_user_groups(user_dn=user_dn, username=username)
group_objs = _ensure_groups(session, groups)
if not group_objs:
user.roles = []
session.add(user)
return
return []
group_ids = [g.id for g in group_objs]
role_ids = list(
@ -43,10 +43,11 @@ def sync_user_ldap_roles(*, session, user: User, username: str, user_dn: str) ->
if not role_ids:
user.roles = []
session.add(user)
return
return []
roles = list(session.scalars(select(Role).where(Role.id.in_(role_ids))))
user.roles = roles
session.add(user)
return roles
def sync_all_ldap_users() -> int:

126
app/security/rbac_seed.py Normal file
View File

@ -0,0 +1,126 @@
from __future__ import annotations
from sqlalchemy import select
from app.db.engine import get_session
from app.db.models import Permission, Role
SYSTEM_ADMIN_ROLE = "system_admin"
READONLY_VIEWER_ROLE = "readonly_viewer"
ADMIN_PERMISSION_CODES = {
"table:jobs:read",
"table:jobs:write",
"table:job_logs:read",
"table:job_logs:write",
"table:users:read",
"table:users:write",
"table:roles:read",
"table:roles:write",
"table:permissions:read",
"table:permissions:write",
"table:ldap_configs:read",
"table:ldap_configs:write",
"table:ldap_groups:read",
"table:ldap_groups:write",
"table:ldap_group_roles:read",
"table:ldap_group_roles:write",
"table:audit_logs:read",
"button:job:run",
"button:job:run_now",
"button:job:view_logs",
"button:job:disable",
"button:job:clear_logs",
"button:job:delete_with_logs",
"button:joblog:retry",
"button:ldap:sync",
"button:ldap:test",
}
READONLY_PERMISSION_CODES = {
"table:jobs:read",
"table:job_logs:read",
}
PERMISSION_DESCRIPTIONS = {
"table:jobs:read": "查看任务菜单和任务详情",
"table:jobs:write": "创建、编辑、删除任务",
"table:job_logs:read": "查看任务日志菜单和日志详情",
"table:job_logs:write": "管理任务日志",
"table:users:read": "查看用户菜单和用户详情",
"table:users:write": "管理用户",
"table:roles:read": "查看角色菜单和角色详情",
"table:roles:write": "管理角色",
"table:permissions:read": "查看权限菜单和权限详情",
"table:permissions:write": "管理权限",
"table:ldap_configs:read": "查看 LDAP 配置",
"table:ldap_configs:write": "管理 LDAP 配置",
"table:ldap_groups:read": "查看 LDAP 组",
"table:ldap_groups:write": "管理 LDAP 组",
"table:ldap_group_roles:read": "查看 LDAP 组角色映射",
"table:ldap_group_roles:write": "管理 LDAP 组角色映射",
"table:audit_logs:read": "查看审计日志",
"button:job:run": "从任务列表立即运行任务",
"button:job:run_now": "通过 SQLAdmin 动作立即运行任务",
"button:job:view_logs": "从任务查看关联日志",
"button:job:disable": "停用任务",
"button:job:clear_logs": "清理任务日志",
"button:job:delete_with_logs": "删除任务及日志",
"button:joblog:retry": "重试任务日志",
"button:ldap:sync": "同步 LDAP 用户角色",
"button:ldap:test": "测试 LDAP 连接",
}
def _ensure_permissions(session, codes: set[str]) -> dict[str, Permission]:
existing = {p.code: p for p in session.scalars(select(Permission).where(Permission.code.in_(codes)))}
for code in sorted(codes):
perm = existing.get(code)
if not perm:
perm = Permission(code=code, description=PERMISSION_DESCRIPTIONS.get(code, ""))
session.add(perm)
session.flush()
existing[code] = perm
elif not perm.description and code in PERMISSION_DESCRIPTIONS:
perm.description = PERMISSION_DESCRIPTIONS[code]
session.add(perm)
return existing
def _ensure_role(session, *, name: str, description: str, permission_codes: set[str]) -> Role:
role = session.scalar(select(Role).where(Role.name == name))
if not role:
role = Role(name=name, description=description)
session.add(role)
session.flush()
elif not role.description:
role.description = description
session.add(role)
perms = _ensure_permissions(session, permission_codes)
current_codes = {p.code for p in role.permissions}
for code in sorted(permission_codes - current_codes):
role.permissions.append(perms[code])
session.add(role)
return role
def seed_standard_rbac() -> None:
db = get_session()
try:
_ensure_role(
db,
name=SYSTEM_ADMIN_ROLE,
description="系统管理员:通过角色获得完整后台管理权限",
permission_codes=ADMIN_PERMISSION_CODES,
)
_ensure_role(
db,
name=READONLY_VIEWER_ROLE,
description="只读查看者:仅可查看任务和任务日志",
permission_codes=READONLY_PERMISSION_CODES,
)
db.commit()
finally:
db.close()

View File

@ -18,11 +18,11 @@ BOOTSTRAP_ADMIN_GENERATE=1
BOOTSTRAP_ADMIN_PASS_PATH=/data/admin.pass
# LDAP
LDAP_URI=ldaps://SHDC01.senasic.cn:636
LDAP_BIND_DN=dcadmin@senasic.cn
LDAP_BIND_PASSWORD=Jj_Window$
LDAP_BASE_DN=ou=People,dc=senasic,dc=com
LDAP_USER_FILTER=(uid={sAMAccountName})
LDAP_URI=ldap://localhost:389
LDAP_BIND_DN=
LDAP_BIND_PASSWORD=
LDAP_BASE_DN=
LDAP_USER_FILTER=(sAMAccountName={username})
LDAP_GROUP_FILTER=(member={user_dn})
LDAP_USE_STARTTLS=0
LDAP_VERIFY_TLS=0