diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 9b581ad..e6d2d59 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -315,6 +315,9 @@ func (s *Server) RegisterRoutes(r *gin.Engine) { admin.GET("/system/identity", idp.Status) admin.GET("/system/identity/profiles", idp.Profiles) admin.POST("/system/identity/directory-check", idp.DirectoryCheck) + admin.GET("/system/identity/directory-sync", idp.DirectorySyncStatus) + admin.PUT("/system/identity/directory-sync", idp.DirectorySyncConfigure) + admin.POST("/system/identity/directory-sync/run", idp.DirectorySyncRun) admin.GET("/tunnels/:id/feishu-grants", idp.Grants) admin.POST("/tunnels/:id/feishu-grants", idp.AddGrant) admin.DELETE("/tunnels/:id/feishu-grants/:grant_id", idp.DeleteGrant) diff --git a/backend/internal/identityprovider/directory_sync.go b/backend/internal/identityprovider/directory_sync.go new file mode 100644 index 0000000..62d5812 --- /dev/null +++ b/backend/internal/identityprovider/directory_sync.go @@ -0,0 +1,414 @@ +package identityprovider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/store" +) + +const contactAPI = "https://open.feishu.cn/open-apis/contact/v3" + +var directoryRunMu sync.Mutex +var directoryRunning bool + +type directorySnapshot struct { + departments map[string]model.FeishuDepartment + members map[string]model.FeishuDirectoryMember +} + +func (p *Provider) DirectorySyncStatus(c *gin.Context) { + noStore(c) + if !secureAdmin(c) { + c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口查看通讯录"}) + return + } + cfg, base, err := enabled() + if err != nil { + c.JSON(409, gin.H{"error": "请先启用飞书身份服务"}) + return + } + status := syncStatus(cfg, base) + var members []model.FeishuDirectoryMember + if err := store.DB.Where("issuer = ? AND app_id = ?", base, cfg.AppID).Order("name asc").Limit(500).Find(&members).Error; err != nil { + c.JSON(500, gin.H{"error": "读取成员失败"}) + return + } + var departments []model.FeishuDepartment + if err := store.DB.Where("issuer = ? AND app_id = ?", base, cfg.AppID).Order("name asc").Limit(500).Find(&departments).Error; err != nil { + c.JSON(500, gin.H{"error": "读取部门失败"}) + return + } + departmentItems := make([]gin.H, 0, len(departments)) + for _, d := range departments { + departmentItems = append(departmentItems, gin.H{"id": d.Department, "name": d.Name, "parent_id": d.Parent, "last_seen_at": d.LastSeenAt}) + } + directoryRunMu.Lock() + running := directoryRunning + directoryRunMu.Unlock() + c.JSON(200, gin.H{"status": status, "running": running, "members": members, "departments": departmentItems}) +} + +func syncStatus(cfg model.IdentityProvider, base string) model.FeishuDirectorySync { + var status model.FeishuDirectorySync + if store.DB.Where("id = ? AND issuer = ? AND app_id = ?", 1, base, cfg.AppID).First(&status).Error != nil { + status = model.FeishuDirectorySync{ID: 1, Issuer: base, AppID: cfg.AppID} + } + return status +} + +func (p *Provider) DirectorySyncConfigure(c *gin.Context) { + noStore(c) + if !secureAdmin(c) { + c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口配置同步"}) + return + } + cfg, base, err := enabled() + if err != nil { + c.JSON(409, gin.H{"error": "请先启用飞书身份服务"}) + return + } + var input struct { + Enabled *bool `json:"enabled"` + } + if c.ShouldBindJSON(&input) != nil || input.Enabled == nil { + c.JSON(400, gin.H{"error": "配置格式错误"}) + return + } + directoryRunMu.Lock() + defer directoryRunMu.Unlock() + status := syncStatus(cfg, base) + status.Enabled = *input.Enabled + if err := store.DB.Save(&status).Error; err != nil { + c.JSON(500, gin.H{"error": "保存同步设置失败"}) + return + } + store.Audit(c.GetString("username"), "feishu_directory_schedule", cfg.AppID, fmt.Sprint(status.Enabled)) + c.JSON(200, gin.H{"status": status}) +} + +func (p *Provider) DirectorySyncRun(c *gin.Context) { + noStore(c) + if !secureAdmin(c) { + c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口同步"}) + return + } + directoryRunMu.Lock() + if directoryRunning { + directoryRunMu.Unlock() + c.JSON(409, gin.H{"error": "同步正在进行"}) + return + } + directoryRunning = true + directoryRunMu.Unlock() + actor := c.GetString("username") + go func() { + defer func() { directoryRunMu.Lock(); directoryRunning = false; directoryRunMu.Unlock() }() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + status, err := performDirectorySync(ctx) + if err != nil { + store.Audit(actor, "feishu_directory_sync_failed", status.AppID, "") + } else { + store.Audit(actor, "feishu_directory_sync", status.AppID, fmt.Sprintf("departments=%d members=%d", status.Departments, status.Members)) + } + }() + c.JSON(202, gin.H{"running": true}) +} + +var errDirectoryBusy = errors.New("directory sync busy") + +func runDirectorySync(ctx context.Context) (model.FeishuDirectorySync, error) { + directoryRunMu.Lock() + if directoryRunning { + directoryRunMu.Unlock() + return model.FeishuDirectorySync{}, errDirectoryBusy + } + directoryRunning = true + directoryRunMu.Unlock() + defer func() { directoryRunMu.Lock(); directoryRunning = false; directoryRunMu.Unlock() }() + return performDirectorySync(ctx) +} + +func performDirectorySync(ctx context.Context) (model.FeishuDirectorySync, error) { + cfg, base, err := enabled() + if err != nil { + return model.FeishuDirectorySync{}, errors.New("飞书身份服务未启用") + } + status := syncStatus(cfg, base) + now := time.Now() + status.LastAttempt = &now + status.LastError = "" + if err := store.DB.Save(&status).Error; err != nil { + return status, errors.New("保存同步状态失败") + } + secret, err := store.DecryptSecret(cfg.AppSecret) + if err != nil { + return failedSync(status, errors.New("读取飞书应用凭据失败")) + } + token, err := directoryTenantToken(ctx, cfg.AppID, secret) + if err != nil { + return failedSync(status, err) + } + snapshot, err := readDirectory(ctx, token) + if err != nil { + return failedSync(status, err) + } + now = time.Now() + status.LastSuccess, status.Departments, status.Members = &now, len(snapshot.departments), len(snapshot.members) + status.LastError = "" + err = store.DB.Transaction(func(tx *gorm.DB) error { + for _, dept := range snapshot.departments { + dept.Issuer, dept.AppID, dept.LastSeenAt = base, cfg.AppID, now + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "issuer"}, {Name: "app_id"}, {Name: "department"}}, DoUpdates: clause.AssignmentColumns([]string{"parent", "name", "last_seen_at"})}).Create(&dept).Error; err != nil { + return err + } + } + for _, member := range snapshot.members { + member.Issuer, member.AppID, member.LastSeenAt = base, cfg.AppID, now + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "issuer"}, {Name: "app_id"}, {Name: "open_id"}}, DoUpdates: clause.AssignmentColumns([]string{"name", "departments", "last_seen_at"})}).Create(&member).Error; err != nil { + return err + } + } + return tx.Save(&status).Error + }) + if err != nil { + return failedSync(status, errors.New("写入通讯录失败")) + } + return status, nil +} + +func failedSync(status model.FeishuDirectorySync, err error) (model.FeishuDirectorySync, error) { + status.LastError = err.Error() + _ = store.DB.Save(&status).Error + return status, err +} + +func directoryTenantToken(ctx context.Context, appID, secret string) (string, error) { + payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": secret}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuTenantToken, strings.NewReader(string(payload))) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := upstreamClient.Do(req) + if err != nil { + return "", errors.New("连接飞书失败") + } + defer resp.Body.Close() + var result struct { + Code *int `json:"code"` + Token string `json:"tenant_access_token"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != http.StatusOK || result.Code == nil || *result.Code != 0 || result.Token == "" { + return "", feishuFailure("获取应用凭据", resp.StatusCode, result.Code) + } + return result.Token, nil +} + +func directoryGet(ctx context.Context, token, path string, query url.Values, out any) error { + u := contactAPI + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := upstreamClient.Do(req) + if err != nil { + return errors.New("飞书通讯录请求失败") + } + defer resp.Body.Close() + var result struct { + Code *int `json:"code"` + Data json.RawMessage `json:"data"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&result) != nil || resp.StatusCode != http.StatusOK || result.Code == nil || *result.Code != 0 { + return feishuFailure("读取飞书通讯录", resp.StatusCode, result.Code) + } + if json.Unmarshal(result.Data, out) != nil { + return errors.New("飞书通讯录返回格式错误") + } + return nil +} + +func readDirectory(ctx context.Context, token string) (directorySnapshot, error) { + snapshot := directorySnapshot{departments: map[string]model.FeishuDepartment{}, members: map[string]model.FeishuDirectoryMember{}} + var scope struct { + DepartmentIDs []string `json:"department_ids"` + UserIDs []string `json:"user_ids"` + } + if err := directoryGet(ctx, token, "/scopes", url.Values{"department_id_type": {"open_department_id"}, "user_id_type": {"open_id"}}, &scope); err != nil { + return snapshot, err + } + if len(scope.DepartmentIDs) == 0 && len(scope.UserIDs) == 0 { + return snapshot, errors.New("飞书应用通讯录可见范围为空;请配置成员和部门可见范围") + } + queue, seen := append([]string{}, scope.DepartmentIDs...), map[string]bool{} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + if id == "" || len(id) > 128 { + return snapshot, errors.New("飞书部门标识无效") + } + if seen[id] { + continue + } + seen[id] = true + if len(seen) > 500 { + return snapshot, errors.New("部门超过同步上限 500") + } + if _, ok := snapshot.departments[id]; !ok { + snapshot.departments[id] = model.FeishuDepartment{Department: id, Name: id} + } + if err := readDirectoryPages(ctx, token, "/departments/"+url.PathEscape(id)+"/children", url.Values{"department_id_type": {"open_department_id"}}, func(raw json.RawMessage) error { + var items []struct { + ID string `json:"open_department_id"` + Name string `json:"name"` + Parent string `json:"parent_department_id"` + } + if err := json.Unmarshal(raw, &items); err != nil { + return err + } + for _, d := range items { + if d.ID == "" || len(d.ID) > 128 || len(d.Name) > 256 { + return errors.New("飞书部门信息无效") + } + snapshot.departments[d.ID] = model.FeishuDepartment{Department: d.ID, Name: d.Name, Parent: d.Parent} + if !seen[d.ID] { + queue = append(queue, d.ID) + } + } + return nil + }); err != nil { + return snapshot, err + } + if err := readDirectoryPages(ctx, token, "/users", url.Values{"department_id": {id}, "department_id_type": {"open_department_id"}, "user_id_type": {"open_id"}}, func(raw json.RawMessage) error { + var items []struct { + OpenID string `json:"open_id"` + Name string `json:"name"` + Departments []string `json:"department_ids"` + } + if err := json.Unmarshal(raw, &items); err != nil { + return err + } + for _, u := range items { + if !strings.HasPrefix(u.OpenID, "ou_") || len(u.OpenID) > 128 || len(u.Name) > 256 { + return errors.New("飞书用户标识或姓名无效") + } + deps := u.Departments + if len(deps) == 0 { + deps = []string{id} + } + if old, ok := snapshot.members[u.OpenID]; ok { + var existing []string + _ = json.Unmarshal([]byte(old.Departments), &existing) + deps = append(deps, existing...) + } + unique := map[string]bool{} + for _, department := range deps { + unique[department] = true + } + deps = deps[:0] + for department := range unique { + deps = append(deps, department) + } + sort.Strings(deps) + encoded, _ := json.Marshal(deps) + snapshot.members[u.OpenID] = model.FeishuDirectoryMember{OpenID: u.OpenID, Name: u.Name, Departments: string(encoded)} + if len(snapshot.members) > 20000 { + return errors.New("成员超过同步上限 20000") + } + } + return nil + }); err != nil { + return snapshot, err + } + } + // Explicitly visible users outside visible departments must also be imported. + for _, id := range scope.UserIDs { + if _, ok := snapshot.members[id]; ok { + continue + } + if !strings.HasPrefix(id, "ou_") || len(id) > 128 { + return snapshot, errors.New("飞书成员标识无效") + } + name, err := fetchBasicName(ctx, token, id) + if err != nil { + return snapshot, errors.New("读取独立可见成员失败;请检查用户基础信息权限") + } + snapshot.members[id] = model.FeishuDirectoryMember{OpenID: id, Name: name, Departments: "[]"} + if len(snapshot.members) > 20000 { + return snapshot, errors.New("成员超过同步上限 20000") + } + } + return snapshot, nil +} + +func readDirectoryPages(ctx context.Context, token, path string, query url.Values, consume func(json.RawMessage) error) error { + visited := map[string]bool{} + for page := 0; page < 300; page++ { + query.Set("page_size", "100") + var data struct { + Items json.RawMessage `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } + if err := directoryGet(ctx, token, path, query, &data); err != nil { + return err + } + if err := consume(data.Items); err != nil { + return errors.New("飞书通讯录分页数据无效") + } + if !data.HasMore { + return nil + } + if data.PageToken == "" || visited[data.PageToken] { + return errors.New("飞书通讯录分页游标无效") + } + visited[data.PageToken] = true + query.Set("page_token", data.PageToken) + } + return errors.New("飞书通讯录分页超过上限") +} + +// StartDirectoryScheduler never imports until an administrator explicitly enables it. +func StartDirectoryScheduler(ctx context.Context) { + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + cfg, base, err := enabled() + if err != nil { + continue + } + status := syncStatus(cfg, base) + if !status.Enabled || (status.LastAttempt != nil && time.Since(*status.LastAttempt) < 30*time.Minute) { + continue + } + runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + _, _ = runDirectorySync(runCtx) + cancel() + } + }() +} diff --git a/backend/internal/identityprovider/directory_sync_test.go b/backend/internal/identityprovider/directory_sync_test.go new file mode 100644 index 0000000..e20a826 --- /dev/null +++ b/backend/internal/identityprovider/directory_sync_test.go @@ -0,0 +1,62 @@ +package identityprovider + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +func TestReadDirectoryTraversesDepartmentsAndPages(t *testing.T) { + previous := upstreamClient + t.Cleanup(func() { upstreamClient = previous }) + calls := map[string]int{} + upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer test-token" { + t.Fatal("missing application token") + } + path := req.URL.Path + calls[path]++ + body := `{"code":0,"data":{"items":[]}}` + switch path { + case "/open-apis/contact/v3/scopes": + body = `{"code":0,"data":{"department_ids":["0"],"user_ids":[]}}` + case "/open-apis/contact/v3/departments/0/children": + body = `{"code":0,"data":{"items":[{"open_department_id":"od_child","name":"研发","parent_department_id":"0"}]}}` + case "/open-apis/contact/v3/users": + if req.URL.Query().Get("department_id") == "0" && req.URL.Query().Get("page_token") == "" { + body = `{"code":0,"data":{"items":[{"open_id":"ou_1","name":"甲","department_ids":["0"]}],"has_more":true,"page_token":"next"}}` + } else if req.URL.Query().Get("department_id") == "0" && req.URL.Query().Get("page_token") == "next" { + body = `{"code":0,"data":{"items":[{"open_id":"ou_2","name":"乙"}]}}` + } else if req.URL.Query().Get("department_id") == "od_child" { + body = `{"code":0,"data":{"items":[{"open_id":"ou_1","name":"甲","department_ids":["od_child"]}]}}` + } else { + t.Fatalf("unexpected page: %s", req.URL) + } + case "/open-apis/contact/v3/departments/od_child/children": + default: + t.Fatalf("unexpected endpoint: %s", req.URL) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + snapshot, err := readDirectory(context.Background(), "test-token") + if err != nil || len(snapshot.departments) != 2 || len(snapshot.members) != 2 || calls["/open-apis/contact/v3/users"] != 3 || snapshot.members["ou_1"].Departments != `["0","od_child"]` { + t.Fatalf("unexpected snapshot: %v, %#v, %#v", err, snapshot, calls) + } +} + +func TestReadDirectoryFailsClosedOnIncompletePage(t *testing.T) { + previous := upstreamClient + t.Cleanup(func() { upstreamClient = previous }) + upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) { + body := `{"code":0,"data":{"department_ids":["0"]}}` + if req.URL.Path != "/open-apis/contact/v3/scopes" { + body = `{"code":0,"data":{"items":[],"has_more":true}}` + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + if _, err := readDirectory(context.Background(), "token"); err == nil || !strings.Contains(err.Error(), "分页游标") { + t.Fatalf("accepted partial directory: %v", err) + } +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 79830eb..76527d2 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -39,6 +39,39 @@ type IdentityProfile struct { UpdatedAt time.Time } +// FeishuDirectorySync tracks a single application's import schedule and outcome. +type FeishuDirectorySync struct { + ID uint `gorm:"primaryKey"` + Enabled bool `json:"enabled"` + Issuer string `gorm:"size:256"` + AppID string `gorm:"size:128"` + LastAttempt *time.Time `json:"last_attempt"` + LastSuccess *time.Time `json:"last_success"` + LastError string `gorm:"size:512" json:"last_error"` + Departments int `json:"departments"` + Members int `json:"members"` +} + +type FeishuDepartment struct { + ID uint `gorm:"primaryKey"` + Issuer string `gorm:"uniqueIndex:idx_feishu_department;size:256"` + AppID string `gorm:"uniqueIndex:idx_feishu_department;size:128"` + Department string `gorm:"uniqueIndex:idx_feishu_department;size:128"` + Parent string `gorm:"size:128"` + Name string `gorm:"size:256"` + LastSeenAt time.Time +} + +type FeishuDirectoryMember struct { + ID uint `gorm:"primaryKey" json:"-"` + Issuer string `gorm:"uniqueIndex:idx_feishu_member;size:256" json:"-"` + AppID string `gorm:"uniqueIndex:idx_feishu_member;size:128" json:"-"` + OpenID string `gorm:"uniqueIndex:idx_feishu_member;size:128" json:"open_id"` + Name string `gorm:"size:256" json:"name"` + Departments string `gorm:"type:text" json:"department_ids"` + LastSeenAt time.Time `json:"last_seen_at"` +} + // FeishuTunnelGrant records explicit admission for a verified Feishu subject. type FeishuTunnelGrant struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 5757f9b..9711ee5 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -44,7 +44,7 @@ func Init(dataDir string) error { } // 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一 db.Exec("DROP INDEX IF EXISTS idx_vpn_user") - if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}, &model.IdentityProvider{}, &model.IdentityGrant{}, &model.IdentityProfile{}, &model.FeishuTunnelGrant{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}, &model.IdentityProvider{}, &model.IdentityGrant{}, &model.IdentityProfile{}, &model.FeishuTunnelGrant{}, &model.FeishuDirectorySync{}, &model.FeishuDepartment{}, &model.FeishuDirectoryMember{}); err != nil { return err } DB = db diff --git a/backend/main.go b/backend/main.go index 3c03981..f134beb 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,6 +2,7 @@ package main import ( + "context" "embed" "fmt" "io/fs" @@ -16,6 +17,7 @@ import ( "vpn-platform/backend/internal/api" "vpn-platform/backend/internal/core" + "vpn-platform/backend/internal/identityprovider" "vpn-platform/backend/internal/monitor" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/service/custom" @@ -51,6 +53,7 @@ func main() { } return } + identityprovider.StartDirectoryScheduler(context.Background()) // 注册内置协议 mgr := core.NewManager() diff --git a/docs/specs/SDD-Feishu-directory-sync.md b/docs/specs/SDD-Feishu-directory-sync.md new file mode 100644 index 0000000..ef0fdd4 --- /dev/null +++ b/docs/specs/SDD-Feishu-directory-sync.md @@ -0,0 +1,66 @@ +# 飞书组织与成员定时导入 + +| 字段 | 内容 | +|---|---| +| 版本 / 变更级别 | 0.1 / S3(通讯录权限、个人信息持久化与服务发布) | +| 设计状态 | 已形成实施方案 | +| 实现状态 | 已实现,待远程验证 | +| 负责人 / 日期 | Codex / 2026-10-23 | +| 关联需求 | 飞书组织成员导入,启用后每 30 分钟同步 | +| 批准状态 | 用户要求开发;飞书应用权限由管理员自行授予 | + +## 1. 背景、目标与范围 + +- 当前行为:[IdentityProfile](../../backend/internal/model/model.go)仅保存完成浏览器登录的成员姓名;[Profiles](../../backend/internal/identityprovider/grants.go)只返回已验证的成员供专线白名单挑选。飞书基础姓名接口已能返回结果,但个别姓名是“用户+编号”。 +- 目标:按应用可见范围读取部门和直属成员,提供管理员手动同步、独立自动同步开关与最近结果;启用后每 30 分钟检查并同步。 +- 范围:单租户、单应用;只导入成员的 open_id、姓名和部门关系。保留现有 VPN 拨号用户、登录档案、专线授权,不修改它们的认证语义。 +- 非目标:自动开通 VPN、自动授予专线白名单、飞书事件订阅、手机号/邮箱导入、跨应用用户合并、反向写飞书。 + +## 2. 需求与约束 + +- FR-01:管理员可在 HTTPS 飞书身份页启用/关闭每 30 分钟同步,并手动触发一次同步;未启用身份服务时拒绝同步。 +- FR-02:先读取通讯录可见范围,逐个遍历部门子节点、分页读取各部门直属成员,按 open_id 去重;无法完成任何一页则本次不写入。 +- FR-03:只在完整读取成功后批量更新导入表和状态;不删除缺席记录,避免范围缩小或临时失权导致误删。不得从导入结果自动给 VPN 授权。 +- FR-04:页面显示同步时间、部门/成员数及失败提示,并列出导入的成员和部门供管理员核对。 +- NFR-01:限制单次运行时长、页数和条数,同一实例内互斥;外部错误不含密钥、token 或通讯录原始报文。 +- NFR-02:仅管理权限、HTTPS 入口可读写导入信息;关闭自动同步不会删除已导入数据。 + +## 3. 架构与方案 + +- Go 身份模块获取应用 tenant token 后调用 `/contact/v3/scopes`、`/departments/:id/children`、`/users`,遍历 scopes 的可见部门和独立用户;仅保存足够显示的字段。 +- 定时器每分钟检查一次启用状态和最近尝试时间;新启用后可立即手动同步,30 分钟后再次自动尝试。失败也等待 30 分钟以免持续打飞书 API。 +- 导入表与已登录档案隔离;已登录档案与白名单保持原有含义。后续若要用导入表作为准入依据,需要另行设计身份绑定和离职策略。 + +## 4. 接口、数据与配置契约 + +- 新增 `GET /api/system/identity/directory-sync`、`PUT /api/system/identity/directory-sync`(enabled)、`POST /api/system/identity/directory-sync/run`。仅 admin + HTTPS;不返回凭据。 +- 新增同步状态、部门、成员表,使用 `(issuer, app_id, id)` 区分应用;保留缺席成员,仅更新本次见到的数据和 `last_seen_at`。 +- 不修改原有登录、专线及 VPNUser API。Fly书权限以实际 API 报错为准:部门与成员列表通常要求部门和用户的应用身份读取权限及足够的通讯录可见范围,页面提供具体引导。 + +## 5. 风险与验收 + +| 风险 | 处理 | 信号 | +|---|---|---| +| 飞书权限不足/范围太窄 | 返回可定位的错误,不覆盖旧数据 | 最近失败状态、飞书错误码 | +| 分页中途失败 | 先读到内存再事务写入 | 上次成功时间不变 | +| 服务重启/重复操作 | 数据库上次尝试时间 + 单实例互斥 | 状态显示运行中或最近尝试时间 | +| 姓名字段被飞书脱敏 | 保留返回姓名,不推断真实身份 | 管理页显示飞书原值 | + +| AC | 对应需求 | 前置/操作 | 预期 | 验证方式 | 结果 | +|---|---|---|---|---|---| +| AC-01 | FR-01 | 启用自动同步并重启 | 下次到期继续运行,关闭后停止 | 代码检查/人工验证 | 待远程验证 | +| AC-02 | FR-02 | 模拟部门分页与成员重复 | 完整遍历、按 open_id 去重 | `go test ./backend/internal/identityprovider -run TestReadDirectory` | 通过 | +| AC-03 | FR-03 | 中间页错误 | 不接受不完整快照,不给新准入 | `TestReadDirectoryFailsClosedOnIncompletePage`;Go 事务代码检查 | 通过 | +| AC-04 | FR-04 | 打开身份页 | 可查看状态及成员摘要 | `npm run build`;页面待人工检查 | 构建通过,页面待验证 | + +## 6. 发布与恢复 + +- 先构建及测试;部署前核对数据库备份和应用进程。首次发布默认不启用定时同步;管理员授权、发布应用并核对可见范围后手动运行再开启。 +- 验证管理健康检查、现有 OpenVPN 进程、同步状态。失败时关闭同步;回滚程序二进制,新增表可保留,不更动拨号数据。 +- 不执行自动删除/反向写飞书;数据存在本地数据库,管理员可通过备份恢复。 + +## 7. 待决问题 + +| 问题 | 阻塞 | 推荐 | +|---|---|---| +| 是否让导入名单自动成为 VPN 准入 | 否 | 此次不变更,另立准入规则与离职失效 SLA | diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 615bde3..27e09d2 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -793,6 +793,9 @@ "setupTitle": "Optional HTTPS public gateway", "setupStepCheck": "Detect system", "setupStepInstall": "Install dependencies", "setupStepConfigure": "Configure gateway and certificate", "detectedSystem": "Server OS", "setupUnsupported": "Automatic setup requires root on Ubuntu or TencentOS Server 4", "setupScope": "Use only if you choose the Nginx gateway. Installs Nginx / Certbot and prepares the validation directory. It does not install VPN protocol dependencies, enable ports, or issue certificates.", "setupButton": "Prepare Nginx / Certbot", "setupComplete": "Dependencies are ready; configure the site and certificate next", "setupFailed": "Initialization failed; inspect the output", "setupInterrupted": "Log connection interrupted; refresh status. Installation may still be running on the server", "commonSetupTitle": "First-time shared network setup", "commonSetupHint": "Check permissions, default route and IPv4 forwarding. Enable shared forwarding only when needed; install protocol-specific dependencies separately in Create Tunnel.", "hostRoot": "Privileges", "hostRoute": "Default route", "hostForward": "IPv4 forwarding", "enableForwarding": "Enable IPv4 forwarding", "forwardingEnabled": "IPv4 forwarding enabled" }, + "feishuSync": { + "title": "Feishu directory", "hint": "Import manually or every 30 minutes when enabled. Imported members do not receive VPN access or tunnel grants. Names are shown exactly as returned by Feishu.", "permissions": "Grant app identity access to department and user lists, publish the app, and set its directory visibility to the intended departments and members.", "auto": "Auto sync (30 min)", "run": "Sync now", "lastSuccess": "Last success", "counts": "Departments {departments} · members {members}", "name": "Name", "departments": "Department IDs", "firstMembers": "Showing the first 500 members", "saved": "Sync settings saved", "started": "Sync started; watch progress below", "done": "Directory sync complete", "failed": "Directory sync failed" + }, "feishuDirectory": { "title": "Feishu directory lookup", "hint": "After sign-in, check the name. The basic name API requires the app identity scope contact:user.basic_profile:readonly and a published app.", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 117909b..fc2c727 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -793,6 +793,9 @@ "setupTitle": "可选 · HTTPS 公网入口", "setupStepCheck": "检测系统", "setupStepInstall": "安装依赖", "setupStepConfigure": "配置入口与证书", "detectedSystem": "服务器系统", "setupUnsupported": "自动初始化仅支持以 root 运行的 Ubuntu 或 TencentOS Server 4", "setupScope": "仅在选择 Nginx 入口时点击:按需安装 Nginx / Certbot 并准备验证目录;不会安装 VPN 协议依赖、启用端口或签发证书。", "setupButton": "准备 Nginx / Certbot", "setupComplete": "依赖已准备,继续配置站点与证书", "setupFailed": "初始化失败,请查看输出", "setupInterrupted": "日志连接中断,请刷新状态;服务器上的安装可能仍在继续", "commonSetupTitle": "首次引导 · 通用网络环境", "commonSetupHint": "检查服务权限、默认出口和 IPv4 转发。仅按需开启通用转发;各专线协议的依赖在“创建专线”中分别安装。", "hostRoot": "运行权限", "hostRoute": "默认出口", "hostForward": "IPv4 转发", "enableForwarding": "开启 IPv4 转发", "forwardingEnabled": "IPv4 转发已开启" }, + "feishuSync": { + "title": "飞书组织与成员", "hint": "手动导入,或开启后每 30 分钟同步一次。导入不会自动开通 VPN 或授予专线权限;仅显示飞书返回的姓名。", "permissions": "需应用身份读取部门及直属成员(如 contact:department.base:readonly、contact:user.department:readonly);发布应用,并设置通讯录可见范围。具体权限以飞书接口报错为准。", "auto": "自动同步(30 分钟)", "run": "立即同步", "lastSuccess": "上次成功", "counts": "部门 {departments} · 成员 {members}", "name": "姓名", "departments": "部门 ID", "firstMembers": "仅显示前 500 位成员", "saved": "同步设置已保存", "started": "已开始同步,可查看下方进度", "done": "通讯录同步完成", "failed": "通讯录同步失败" + }, "feishuDirectory": { "title": "飞书姓名查询", "hint": "完成登录后可检查姓名。基础姓名接口需应用身份权限 contact:user.basic_profile:readonly,并发布应用。", diff --git a/frontend/src/views/settings/components/IdentitySettings.vue b/frontend/src/views/settings/components/IdentitySettings.vue index 7a8fc96..947f77d 100644 --- a/frontend/src/views/settings/components/IdentitySettings.vue +++ b/frontend/src/views/settings/components/IdentitySettings.vue @@ -56,6 +56,28 @@ + + +

{{ $t('feishuSync.hint') }}

+

{{ $t('feishuSync.permissions') }}

+
+ + {{ $t('feishuSync.run') }} + {{ $t('tls.refresh') }} +
+

{{ $t('feishuSync.lastSuccess') }}:{{ syncStatus.last_success ? new Date(syncStatus.last_success).toLocaleString() : '—' }} · {{ $t('feishuSync.counts', { departments: syncStatus.departments || 0, members: syncStatus.members || 0 }) }}

+ + + + + + + + + + +

{{ $t('feishuSync.firstMembers') }}

+
@@ -87,13 +109,31 @@ const profilesLoading = ref(false) const directoryLoading = ref(false) const directoryResult = ref('') const directoryAvailable = ref(false) +const syncStatus = ref({}) +const syncMembers = ref([]) +const syncDepartments = ref([]) +const syncDepartmentTree = computed(() => { + const nodes = new Map(syncDepartments.value.map(d => [d.id, { ...d, children: [] }])) + const roots = [] + for (const node of nodes.values()) { + const parent = nodes.get(node.parent_id) + if (parent && parent !== node) parent.children.push(node) + else roots.push(node) + } + return roots +}) +const syncError = ref('') +const syncLoading = ref(false) +const syncSaving = ref(false) +const syncRunning = ref(false) +let syncPollTimer const basicProfileVerified = ref(false) const userScopeVerified = computed(() => !!identityStatus.value?.user_scope_verified && identityForm.value.app_id === identityStatus.value?.app_id && !identityForm.value.app_secret && identityForm.value.tenant_key === identityStatus.value?.tenant_key) const tenantSeconds = ref(0) let tenantTimer let identityCheckTimer let tenantRequest = 0 -onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); tenantRequest++ }) +onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); clearTimeout(syncPollTimer); tenantRequest++ }) watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => { if (appId === oldId && secret === oldSecret) return tenantRequest++ @@ -142,7 +182,7 @@ async function loadIdentity() { try { const { data } = await api.get('/system/identity') identityStatus.value = data - if (data.enabled && identitySecure) loadProfiles() + if (data.enabled && identitySecure) { loadProfiles(); loadSync() } identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' } tenantName.value = '' tenantError.value = '' @@ -170,6 +210,38 @@ async function checkDirectory() { } catch (e) { if (subject === selectedProfile.value) { directoryAvailable.value = false; basicProfileVerified.value = false; directoryResult.value = e.response?.data?.error || t('feishuDirectory.failed') } } finally { directoryLoading.value = false } } +async function loadSync() { + syncLoading.value = true + try { + const { data } = await api.get('/system/identity/directory-sync') + syncStatus.value = data.status || {} + syncMembers.value = data.members || [] + syncDepartments.value = data.departments || [] + syncError.value = '' + syncRunning.value = !!data.running + clearTimeout(syncPollTimer) + if (data.running) syncPollTimer = setTimeout(loadSync, 3000) + } catch (e) { syncError.value = e.response?.data?.error || t('feishuSync.failed') } + finally { syncLoading.value = false } +} +async function setSyncEnabled(enabled) { + syncSaving.value = true + try { + const { data } = await api.put('/system/identity/directory-sync', { enabled }) + syncStatus.value = data.status + ElMessage.success(t('feishuSync.saved')) + } catch (e) { ElMessage.error(e.response?.data?.error || t('feishuSync.failed')) } + finally { syncSaving.value = false } +} +async function runSync() { + syncRunning.value = true + syncError.value = '' + try { await api.post('/system/identity/directory-sync/run'); clearTimeout(syncPollTimer); syncPollTimer = setTimeout(loadSync, 1500); ElMessage.success(t('feishuSync.started')) } + catch (e) { syncRunning.value = false; syncError.value = e.response?.data?.error || t('feishuSync.failed') } +} +function memberDepartments(value) { + try { return JSON.parse(value || '[]').join(', ') || '—' } catch { return '—' } +} async function saveIdentity() { if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly')) identitySaving.value = true