update
This commit is contained in:
parent
adb93ae6cc
commit
7050c80632
19
README.md
19
README.md
|
|
@ -113,6 +113,25 @@ ConnectHub 是一个轻量级企业集成中间件:统一管理多系统集成
|
||||||
- Worker 执行前自动解密,仅在内存中传给 Job
|
- Worker 执行前自动解密,仅在内存中传给 Job
|
||||||
- key 自动生成到 `FERNET_KEY_PATH`(默认 `/data/fernet.key`),volume 挂载后可持久化
|
- 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(动态加载)
|
#### PluginManager(动态加载)
|
||||||
|
|
||||||
- 位置:`app/plugins/manager.py`
|
- 位置:`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.engine import get_session
|
||||||
from app.db.models import JobStatus
|
from app.db.models import JobStatus
|
||||||
from app.db.models import Job, JobLog
|
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.plugins.manager import load_job_class
|
||||||
from app.security.fernet import encrypt_json
|
from app.security.fernet import encrypt_json
|
||||||
from app.tasks.execute import execute_job
|
from app.tasks.execute import execute_job
|
||||||
from app.admin.secure import SecureModelView
|
from app.admin.secure import SecureModelView
|
||||||
from app.security.permissions import button_permission_code
|
from app.security.permissions import button_permission_code
|
||||||
from app.security.audit import log_event
|
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.ldap_sync import sync_all_ldap_users
|
||||||
from app.security.auth import hash_password
|
from app.security.auth import hash_password
|
||||||
|
from app.security.session import get_current_user
|
||||||
|
|
||||||
|
|
||||||
def _maybe_json(value: Any) -> Any:
|
def _maybe_json(value: Any) -> Any:
|
||||||
|
|
@ -533,6 +536,144 @@ class PermissionAdmin(SecureModelView, model=Permission):
|
||||||
session.close()
|
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):
|
class LdapGroupAdmin(SecureModelView, model=LdapGroup):
|
||||||
name = "LDAP组"
|
name = "LDAP组"
|
||||||
name_plural = "LDAP组"
|
name_plural = "LDAP组"
|
||||||
|
|
@ -572,6 +713,12 @@ class LdapGroupRoleAdmin(SecureModelView, model=LdapGroupRole):
|
||||||
name = "LDAP组映射"
|
name = "LDAP组映射"
|
||||||
name_plural = "LDAP组映射"
|
name_plural = "LDAP组映射"
|
||||||
icon = "fa fa-random"
|
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]
|
async def on_model_change(self, data: dict, model: LdapGroupRole, is_created: bool, request) -> None: # type: ignore[override]
|
||||||
session = get_session()
|
session = get_session()
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,9 @@ class Role(Base):
|
||||||
users: Mapped[list[User]] = relationship(secondary=user_roles, back_populates="roles")
|
users: Mapped[list[User]] = relationship(secondary=user_roles, back_populates="roles")
|
||||||
permissions: Mapped[list["Permission"]] = relationship(secondary=role_permissions, back_populates="roles")
|
permissions: Mapped[list["Permission"]] = relationship(secondary=role_permissions, back_populates="roles")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Permission(Base):
|
class Permission(Base):
|
||||||
__tablename__ = "permissions"
|
__tablename__ = "permissions"
|
||||||
|
|
@ -125,6 +128,9 @@ class LdapGroup(Base):
|
||||||
dn: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
dn: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String, 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):
|
class LdapGroupRole(Base):
|
||||||
__tablename__ = "ldap_group_roles"
|
__tablename__ = "ldap_group_roles"
|
||||||
|
|
@ -133,6 +139,32 @@ class LdapGroupRole(Base):
|
||||||
ldap_group_id: Mapped[int] = mapped_column(ForeignKey("ldap_groups.id"), nullable=False)
|
ldap_group_id: Mapped[int] = mapped_column(ForeignKey("ldap_groups.id"), nullable=False)
|
||||||
role_id: Mapped[int] = mapped_column(ForeignKey("roles.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):
|
class AuditLog(Base):
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from app.admin.views import (
|
||||||
AuditLogAdmin,
|
AuditLogAdmin,
|
||||||
JobAdmin,
|
JobAdmin,
|
||||||
JobLogAdmin,
|
JobLogAdmin,
|
||||||
|
LdapConfigAdmin,
|
||||||
LdapGroupAdmin,
|
LdapGroupAdmin,
|
||||||
LdapGroupRoleAdmin,
|
LdapGroupRoleAdmin,
|
||||||
PermissionAdmin,
|
PermissionAdmin,
|
||||||
|
|
@ -25,6 +26,8 @@ from app.security.bootstrap import bootstrap_admin
|
||||||
from app.security.session import get_current_user
|
from app.security.session import get_current_user
|
||||||
from app.security.fernet import get_or_create_fernet_key
|
from app.security.fernet import get_or_create_fernet_key
|
||||||
from app.api.auth_routes import router as auth_router
|
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:
|
def _init_db() -> None:
|
||||||
|
|
@ -40,6 +43,8 @@ def _ensure_runtime() -> None:
|
||||||
get_or_create_fernet_key(settings.fernet_key_path)
|
get_or_create_fernet_key(settings.fernet_key_path)
|
||||||
_init_db()
|
_init_db()
|
||||||
bootstrap_admin()
|
bootstrap_admin()
|
||||||
|
ensure_default_ldap_config()
|
||||||
|
seed_standard_rbac()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|
@ -57,6 +62,7 @@ def create_app() -> FastAPI:
|
||||||
admin.add_view(UserAdmin)
|
admin.add_view(UserAdmin)
|
||||||
admin.add_view(RoleAdmin)
|
admin.add_view(RoleAdmin)
|
||||||
admin.add_view(PermissionAdmin)
|
admin.add_view(PermissionAdmin)
|
||||||
|
admin.add_view(LdapConfigAdmin)
|
||||||
admin.add_view(LdapGroupAdmin)
|
admin.add_view(LdapGroupAdmin)
|
||||||
admin.add_view(LdapGroupRoleAdmin)
|
admin.add_view(LdapGroupRoleAdmin)
|
||||||
admin.add_view(AuditLogAdmin)
|
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:
|
def authenticate_ldap(username: str, password: str, request=None) -> int | None:
|
||||||
client = LdapClient()
|
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:
|
if not result:
|
||||||
db = get_session()
|
db = get_session()
|
||||||
try:
|
try:
|
||||||
|
|
@ -72,7 +88,19 @@ def authenticate_ldap(username: str, password: str, request=None) -> int | None:
|
||||||
user.is_ldap = True
|
user.is_ldap = True
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
user.last_login_at = datetime.utcnow()
|
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.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
log_event(db, action="login.success", target=username, detail={"provider": "ldap"}, request=request, actor=user)
|
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 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:
|
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
|
tls = None
|
||||||
if settings.ldap_uri.startswith("ldaps://") or settings.ldap_use_starttls:
|
if self.config.uri.startswith("ldaps://") or self.config.use_starttls:
|
||||||
tls = Tls(validate=ssl.CERT_REQUIRED if settings.ldap_verify_tls else ssl.CERT_NONE)
|
tls = Tls(validate=ssl.CERT_REQUIRED if self.config.verify_tls else ssl.CERT_NONE)
|
||||||
self._server = Server(settings.ldap_uri, use_ssl=settings.ldap_uri.startswith("ldaps://"), get_info=ALL, tls=tls)
|
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:
|
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 = Connection(self._server, user=bind_dn, password=password, auto_bind=False)
|
||||||
conn.open()
|
conn.open()
|
||||||
if settings.ldap_use_starttls and self._server.ssl is False:
|
if self.config and self.config.use_starttls and self._server.ssl is False:
|
||||||
conn.start_tls()
|
if not conn.start_tls():
|
||||||
conn.bind()
|
raise RuntimeError(f"LDAP StartTLS failed: {conn.result}")
|
||||||
|
if not conn.bind():
|
||||||
|
raise RuntimeError(f"LDAP bind failed: {conn.result}")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def _service_conn(self) -> Connection:
|
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:
|
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
|
return None
|
||||||
conn = self._service_conn()
|
conn = self._service_conn()
|
||||||
try:
|
try:
|
||||||
search_filter = settings.ldap_user_filter.format(username=username)
|
search_filter = self._format_filter(self.config.user_filter, username=username)
|
||||||
conn.search(settings.ldap_base_dn, search_filter, attributes=["dn"])
|
conn.search(self.config.base_dn, search_filter, attributes=["dn"])
|
||||||
if not conn.entries:
|
if not conn.entries:
|
||||||
return None
|
return None
|
||||||
return conn.entries[0].entry_dn
|
return conn.entries[0].entry_dn
|
||||||
|
|
@ -51,12 +70,12 @@ class LdapClient:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_groups(self, *, user_dn: str, username: str) -> list[dict[str, str]]:
|
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 []
|
return []
|
||||||
conn = self._service_conn()
|
conn = self._service_conn()
|
||||||
try:
|
try:
|
||||||
search_filter = settings.ldap_group_filter.format(user_dn=user_dn, username=username)
|
search_filter = self._format_filter(self.config.group_filter, user_dn=user_dn, username=username)
|
||||||
conn.search(settings.ldap_base_dn, search_filter, attributes=["cn"])
|
conn.search(self.config.base_dn, search_filter, attributes=["cn"])
|
||||||
results: list[dict[str, str]] = []
|
results: list[dict[str, str]] = []
|
||||||
for entry in conn.entries:
|
for entry in conn.entries:
|
||||||
dn = entry.entry_dn
|
dn = entry.entry_dn
|
||||||
|
|
@ -69,3 +88,21 @@ class LdapClient:
|
||||||
return results
|
return results
|
||||||
finally:
|
finally:
|
||||||
conn.unbind()
|
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
|
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()
|
client = LdapClient()
|
||||||
groups = client.get_user_groups(user_dn=user_dn, username=username)
|
groups = client.get_user_groups(user_dn=user_dn, username=username)
|
||||||
group_objs = _ensure_groups(session, groups)
|
group_objs = _ensure_groups(session, groups)
|
||||||
if not group_objs:
|
if not group_objs:
|
||||||
user.roles = []
|
user.roles = []
|
||||||
session.add(user)
|
session.add(user)
|
||||||
return
|
return []
|
||||||
|
|
||||||
group_ids = [g.id for g in group_objs]
|
group_ids = [g.id for g in group_objs]
|
||||||
role_ids = list(
|
role_ids = list(
|
||||||
|
|
@ -43,10 +43,11 @@ def sync_user_ldap_roles(*, session, user: User, username: str, user_dn: str) ->
|
||||||
if not role_ids:
|
if not role_ids:
|
||||||
user.roles = []
|
user.roles = []
|
||||||
session.add(user)
|
session.add(user)
|
||||||
return
|
return []
|
||||||
roles = list(session.scalars(select(Role).where(Role.id.in_(role_ids))))
|
roles = list(session.scalars(select(Role).where(Role.id.in_(role_ids))))
|
||||||
user.roles = roles
|
user.roles = roles
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
def sync_all_ldap_users() -> int:
|
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
|
BOOTSTRAP_ADMIN_PASS_PATH=/data/admin.pass
|
||||||
|
|
||||||
# LDAP
|
# LDAP
|
||||||
LDAP_URI=ldaps://SHDC01.senasic.cn:636
|
LDAP_URI=ldap://localhost:389
|
||||||
LDAP_BIND_DN=dcadmin@senasic.cn
|
LDAP_BIND_DN=
|
||||||
LDAP_BIND_PASSWORD=Jj_Window$
|
LDAP_BIND_PASSWORD=
|
||||||
LDAP_BASE_DN=ou=People,dc=senasic,dc=com
|
LDAP_BASE_DN=
|
||||||
LDAP_USER_FILTER=(uid={sAMAccountName})
|
LDAP_USER_FILTER=(sAMAccountName={username})
|
||||||
LDAP_GROUP_FILTER=(member={user_dn})
|
LDAP_GROUP_FILTER=(member={user_dn})
|
||||||
LDAP_USE_STARTTLS=0
|
LDAP_USE_STARTTLS=0
|
||||||
LDAP_VERIFY_TLS=0
|
LDAP_VERIFY_TLS=0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue