update
This commit is contained in:
parent
adb93ae6cc
commit
7050c80632
19
README.md
19
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`
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
10
env.example
10
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue