- plan.go:新 diff 引擎(纯函数);输入=镜像期望状态 BuildFromMirror + LLDAP 实际 LDAP 视图;操作顺序 分组→用户→加组→移组→删除,删除仅 confirmed_leave 且最后阶段; 所有权只认镜像台账,服务账号永不碰;孤儿组只告警不自动删 - exec.go:写走 lldapgql;deleteUser 级联清成员;groupId 用 dept_id→groupId 映射 (物化开始时经 GraphQL Groups 拉取),不凭名字反推 - runner.go:两阶段编排,快照与物化共享单实例互斥锁;物化开始登录一次、 全成功才回写 attrs_hash/groupId/MarkDeleted - main.go:-snapshot/-apply/-dry-run 命令行 + 两套定时任务(快照低频/物化略高频)+ check-config - server.go:镜像视图用户/部门、待确认列表、快照触发、一次性离职确认端点 (不经 Auth,token 即凭证)、skipped 展示 - 安全结构拦截均已配变异验证用例(plan_test):未确认不删、非受管不删、 admin 永不移出、删除最后阶段
384 lines
12 KiB
Go
384 lines
12 KiB
Go
// Package server 提供 WebUI 所需的 HTTP 接口、静态资源,以及一次性离职确认端点。
|
|
//
|
|
// 认证只保护管理界面本身(单管理员会话),与 LDAP 用户无关。离职确认端点
|
|
// **不经 Auth 中间件**——token 即凭证,点击飞书卡片跳到它,校验通过才置
|
|
// confirmed_leave。
|
|
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"idsync/internal/config"
|
|
"idsync/internal/ldaplib"
|
|
"idsync/internal/store"
|
|
"idsync/internal/syncer"
|
|
"idsync/internal/webui"
|
|
)
|
|
|
|
// Deps 是服务器依赖。
|
|
type Deps struct {
|
|
Cfg *config.Config
|
|
Auth *Auth
|
|
Store *store.Store
|
|
Runner *syncer.Runner
|
|
LDAP *ldaplib.Client
|
|
// FeishuTest 检查飞书连通性
|
|
FeishuTest func(ctx context.Context) error
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// Server 装配路由。
|
|
type Server struct {
|
|
deps *Deps
|
|
mux *http.ServeMux
|
|
// syncMu 保证同一时刻只跑一次同步,避免两次点击互相踩踏。
|
|
syncMu chan struct{}
|
|
}
|
|
|
|
// New 构造服务器。
|
|
func New(d *Deps) *Server {
|
|
s := &Server{
|
|
deps: d,
|
|
mux: http.NewServeMux(),
|
|
syncMu: make(chan struct{}, 1),
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
// Handler 返回可挂载到 http.Server 的处理器。
|
|
func (s *Server) Handler() http.Handler { return s.mux }
|
|
|
|
func (s *Server) routes() {
|
|
// 静态资源与登录页不需要认证。
|
|
s.mux.HandleFunc("/", s.handleIndex)
|
|
s.mux.HandleFunc("/static/", webui.AssetHandler())
|
|
|
|
s.mux.HandleFunc("/api/login", s.handleLogin)
|
|
s.mux.HandleFunc("/api/logout", s.handleLogout)
|
|
|
|
// 离职确认端点:token 即凭证,不经 Auth 中间件。
|
|
s.mux.HandleFunc(config.ResignConfirmRoute, s.handleResignConfirm)
|
|
|
|
// 以下接口需要登录。
|
|
s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus))
|
|
s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers))
|
|
s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups))
|
|
s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan))
|
|
s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync))
|
|
s.mux.HandleFunc("/api/snapshot", s.deps.Auth.Middleware(s.handleSnapshot))
|
|
s.mux.HandleFunc("/api/resign/pending", s.deps.Auth.Middleware(s.handleResignPending))
|
|
s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns))
|
|
}
|
|
|
|
// handleIndex 返回内嵌的前端页面;未知的 /api/ 路径返回 404 而非首页。
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "接口不存在"})
|
|
return
|
|
}
|
|
webui.IndexHandler()(w, r)
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
|
return
|
|
}
|
|
if err := s.deps.Auth.Login(w, r); err != nil {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
s.deps.Auth.Logout(w)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleStatus 返回概览信息。
|
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
|
|
cfg := s.deps.Cfg
|
|
|
|
ldapOK, ldapErr := true, ""
|
|
if err := s.deps.LDAP.Ping(); err != nil {
|
|
ldapOK, ldapErr = false, err.Error()
|
|
}
|
|
feishuOK, feishuErr := true, ""
|
|
if s.deps.FeishuTest != nil {
|
|
if err := s.deps.FeishuTest(ctx); err != nil {
|
|
feishuOK, feishuErr = false, err.Error()
|
|
}
|
|
}
|
|
|
|
users, err := s.deps.Store.ActiveUsers()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
depts, err := s.deps.Store.ActiveDepartments()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
skipped, _ := s.deps.Store.SkippedUsers()
|
|
pending, _ := s.deps.Store.PendingConfirm()
|
|
|
|
lastRun, err := s.deps.Store.LastRun()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ldap": map[string]any{
|
|
"ok": ldapOK, "error": ldapErr,
|
|
"url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, "userOu": cfg.UserOUDN(),
|
|
},
|
|
"feishu": map[string]any{
|
|
"ok": feishuOK, "error": feishuErr,
|
|
"appId": maskSecret(cfg.Feishu.AppID), "deptList": cfg.Feishu.DeptList,
|
|
},
|
|
"lldap": map[string]any{
|
|
"baseUrl": cfg.Lldap.BaseURL,
|
|
},
|
|
"counts": map[string]any{
|
|
"activeUsers": len(users), "departments": len(depts),
|
|
"pendingConfirm": len(pending), "skipped": len(skipped),
|
|
},
|
|
"lastRun": lastRun,
|
|
"schedule": map[string]any{
|
|
"enabled": cfg.Schedule.Enable, "snapshot": cfg.Schedule.Snapshot,
|
|
"sync": cfg.Schedule.Sync, "syncDryRun": cfg.Schedule.SyncDryRun,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleUsers 返回镜像用户视图(含在职与离职流程中的用户)。
|
|
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
|
all, err := s.deps.Store.AllMirrorUsers()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
type row struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
State string `json:"state"`
|
|
Skipped bool `json:"skipped"`
|
|
SkipReason string `json:"skipReason,omitempty"`
|
|
DeptIDs []string `json:"deptIds"`
|
|
LastSync string `json:"lastSyncedAt"`
|
|
}
|
|
rows := make([]row, 0, len(all))
|
|
for _, u := range all {
|
|
deptIDs, _ := s.deps.Store.UserDeptIDs(u.UID)
|
|
rows = append(rows, row{
|
|
UID: u.UID, Name: u.DisplayName, Email: u.Email, State: u.State,
|
|
Skipped: u.Skipped, SkipReason: u.SkipReason,
|
|
DeptIDs: deptIDs, LastSync: timeOrZero(u.LastSyncedAt),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": rows})
|
|
}
|
|
|
|
// handleGroups 返回部门镜像。
|
|
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
|
|
depts, err := s.deps.Store.AllDepartments()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"groups": depts})
|
|
}
|
|
|
|
// handlePlan 计算并返回 dry-run 物化计划,不产生任何写入。
|
|
func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
|
defer cancel()
|
|
plan, err := s.deps.Runner.Preview(ctx)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, plan)
|
|
}
|
|
|
|
// handleSync 执行物化。默认 dry-run,只有显式传 dryRun=false 才真正写入。
|
|
func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
|
return
|
|
}
|
|
var req struct {
|
|
DryRun *bool `json:"dryRun"`
|
|
Force bool `json:"force"`
|
|
}
|
|
if r.Body != nil {
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
}
|
|
dryRun := true
|
|
if req.DryRun != nil {
|
|
dryRun = *req.DryRun
|
|
}
|
|
|
|
if err := s.lockSync(); err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
defer s.unlockSync()
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
|
defer cancel()
|
|
plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual")
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "plan": plan})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"dryRun": dryRun, "plan": plan, "result": res})
|
|
}
|
|
|
|
// handleSnapshot 触发一轮快照。
|
|
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
|
return
|
|
}
|
|
if err := s.lockSync(); err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
defer s.unlockSync()
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
|
defer cancel()
|
|
res, err := s.deps.Runner.RunSnapshot(ctx)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
}
|
|
|
|
// handleResignPending 返回待人工确认的离职候选(等价于卡片按钮,可在界面操作)。
|
|
func (s *Server) handleResignPending(w http.ResponseWriter, r *http.Request) {
|
|
list, err := s.deps.Store.PendingConfirm()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
type row struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
LastSync string `json:"lastSyncedAt"`
|
|
}
|
|
rows := make([]row, 0, len(list))
|
|
for _, u := range list {
|
|
rows = append(rows, row{UID: u.UID, Name: u.DisplayName, Email: u.Email, LastSync: timeOrZero(u.LastSyncedAt)})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"pending": rows})
|
|
}
|
|
|
|
// handleResignConfirm 处理一次性离职确认:校验 token 并通过后置 confirmed_leave。
|
|
//
|
|
// token 即凭证,因此本项目路由不经 Auth 中间件。成功后渲染极简确认页,不回显
|
|
// 用户敏感信息;无效/过期/已用分别给出对应提示。
|
|
func (s *Server) handleResignConfirm(w http.ResponseWriter, r *http.Request) {
|
|
uid := r.URL.Query().Get("uid")
|
|
token := r.URL.Query().Get("token")
|
|
|
|
rec, err := s.deps.Store.ConsumeToken(token)
|
|
if err != nil {
|
|
s.renderConfirmPage(w, "确认链接无效", "该确认链接暂不可用,可能是已失效、已使用或已过期。请向管理员重新发起离职确认。")
|
|
return
|
|
}
|
|
// token 与请求里携带的 uid 必须一致,防串用。
|
|
if rec.UID != uid {
|
|
s.renderConfirmPage(w, "确认链接无效", "该确认链接与请求的用户不一致,已拒绝。")
|
|
return
|
|
}
|
|
if err := s.deps.Store.ConfirmLeave(uid); err != nil {
|
|
s.log().Warn("确认离职失败", "uid", uid, "err", err)
|
|
s.renderConfirmPage(w, "操作失败", "处理确认时出错,请稍后重试或联系管理员。")
|
|
return
|
|
}
|
|
s.log().Info("用户已人工确认离职", "uid", uid)
|
|
s.renderConfirmPage(w, "已确认离职", "该员工已标记为离职,将在下一轮同步中从目录移除。")
|
|
}
|
|
|
|
// renderConfirmPage 渲染极简确认页,不展示任何用户敏感信息。
|
|
func (s *Server) renderConfirmPage(w http.ResponseWriter, title, body string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh"><meta charset="utf-8">
|
|
<title>%s</title><body style="font-family:sans-serif;padding:2rem">
|
|
<h1>%s</h1><p>%s</p></body></html>`,
|
|
html.EscapeString(title), html.EscapeString(title), html.EscapeString(body))
|
|
}
|
|
|
|
// handleRuns 返回物化历史。
|
|
func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
|
runs, err := s.deps.Store.RecentRuns(100)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
|
|
}
|
|
|
|
func (s *Server) lockSync() error {
|
|
select {
|
|
case s.syncMu <- struct{}{}:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("已有快照或物化任务在执行")
|
|
}
|
|
}
|
|
|
|
func (s *Server) unlockSync() { <-s.syncMu }
|
|
|
|
func (s *Server) log() *slog.Logger {
|
|
if s.deps.Log != nil {
|
|
return s.deps.Log
|
|
}
|
|
return slog.Default()
|
|
}
|
|
|
|
// writeJSON 输出 JSON 响应。
|
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
// maskSecret 只保留标识的前后几位。
|
|
func maskSecret(s string) string {
|
|
if len(s) <= 8 {
|
|
if len(s) == 0 {
|
|
return "未配置"
|
|
}
|
|
return "****"
|
|
}
|
|
return s[:4] + "****" + s[len(s)-4:]
|
|
}
|
|
|
|
func timeOrZero(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
} |