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() }} + +
"
+ + 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