From 7050c80632eb9b7f2d6ed8f91c8cda55c1351b2b Mon Sep 17 00:00:00 2001 From: Marsway Date: Thu, 14 May 2026 16:50:06 +0800 Subject: [PATCH] update --- README.md | 19 +++ app/admin/templates/ldap_config_edit.html | 43 +++++++ app/admin/views.py | 149 +++++++++++++++++++++- app/db/models.py | 32 +++++ app/main.py | 6 + app/security/auth.py | 32 ++++- app/security/ldap_client.py | 67 +++++++--- app/security/ldap_config.py | 104 +++++++++++++++ app/security/ldap_sync.py | 7 +- app/security/rbac_seed.py | 126 ++++++++++++++++++ env.example | 10 +- 11 files changed, 569 insertions(+), 26 deletions(-) create mode 100644 app/admin/templates/ldap_config_edit.html create mode 100644 app/security/ldap_config.py create mode 100644 app/security/rbac_seed.py diff --git a/README.md b/README.md index 48b7dc0..f2357bc 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/app/admin/templates/ldap_config_edit.html b/app/admin/templates/ldap_config_edit.html new file mode 100644 index 0000000..699f3fe --- /dev/null +++ b/app/admin/templates/ldap_config_edit.html @@ -0,0 +1,43 @@ +{% extends "sqladmin/edit.html" %} + +{% block content %} + {{ super() }} + +
+
+
+ +
+ +
+ 出于安全考虑,编辑页不回显历史密码。留空表示不修改;填写新密码将覆盖旧密码并加密落库。 +
+
+
+
+
+{% endblock %} + +{% block tail %} + {{ super() }} + +{% endblock %} diff --git a/app/admin/views.py b/app/admin/views.py index 0bf373f..26eaa92 100644 --- a/app/admin/views.py +++ b/app/admin/views.py @@ -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( + "
"
+            + json.dumps(m.last_test_result or {}, ensure_ascii=False, indent=2, sort_keys=True)
+            + "
" + ), + 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() diff --git a/app/db/models.py b/app/db/models.py index b9e7a29..2f33a0d 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -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" diff --git a/app/main.py b/app/main.py index 7d71e43..41ee775 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/security/auth.py b/app/security/auth.py index 082f6c1..1ae97e2 100644 --- a/app/security/auth.py +++ b/app/security/auth.py @@ -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) diff --git a/app/security/ldap_client.py b/app/security/ldap_client.py index 8896b36..6105572 100644 --- a/app/security/ldap_client.py +++ b/app/security/ldap_client.py @@ -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() diff --git a/app/security/ldap_config.py b/app/security/ldap_config.py new file mode 100644 index 0000000..8d13f92 --- /dev/null +++ b/app/security/ldap_config.py @@ -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 diff --git a/app/security/ldap_sync.py b/app/security/ldap_sync.py index fae746f..730c9bd 100644 --- a/app/security/ldap_sync.py +++ b/app/security/ldap_sync.py @@ -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: diff --git a/app/security/rbac_seed.py b/app/security/rbac_seed.py new file mode 100644 index 0000000..13984b1 --- /dev/null +++ b/app/security/rbac_seed.py @@ -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() diff --git a/env.example b/env.example index cf21318..f3e86d7 100644 --- a/env.example +++ b/env.example @@ -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