Check Feishu directory scope and highlight app availability

This commit is contained in:
Codex committed 2026-10-23 14:35:09 +08:00
1 parent bf9e1c3c2c
commit e2597719c8
7 files changed
+147 -4

No files matched your search

+1
View File
@@ -316,6 +316,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
admin.GET("/system/identity/profiles", idp.Profiles)
admin.POST("/system/identity/directory-check", idp.DirectoryCheck)
admin.GET("/system/identity/directory-sync", idp.DirectorySyncStatus)
admin.GET("/system/identity/directory-sync/check", idp.DirectorySyncCheck)
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)
@@ -32,6 +32,70 @@ type directorySnapshot struct {
visibleUsers int
}
// DirectorySyncCheck probes only the app-visible scope and the first page of
// directory endpoints. It cannot attest to app publication or all-employee scope.
func (p *Provider) DirectorySyncCheck(c *gin.Context) {
noStore(c)
if !secureAdmin(c) {
c.JSON(403, gin.H{"error": "请通过可信 HTTPS 管理入口检测飞书权限"})
return
}
cfg, _, err := enabled()
if err != nil {
c.JSON(409, gin.H{"error": "请先启用飞书身份服务"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 22*time.Second)
defer cancel()
secret, err := store.DecryptSecret(cfg.AppSecret)
if err != nil {
c.JSON(503, gin.H{"error": "读取飞书应用凭据失败"})
return
}
result, err := checkDirectoryAccess(ctx, cfg.AppID, secret)
if err != nil {
c.JSON(502, gin.H{"error": err.Error()})
return
}
c.JSON(200, result)
}
func checkDirectoryAccess(ctx context.Context, appID, secret string) (gin.H, error) {
token, err := directoryTenantToken(ctx, appID, secret)
if err != nil {
return nil, err
}
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 nil, err
}
result := gin.H{"visible_departments": len(scope.DepartmentIDs), "visible_users": len(scope.UserIDs), "department_access": "unverified", "user_list_access": "unverified"}
if len(scope.DepartmentIDs) == 0 {
return result, nil
}
id := scope.DepartmentIDs[0]
if id == "" || len(id) > 128 {
return nil, errors.New("飞书部门标识无效")
}
var ignored struct{}
if err := directoryGet(ctx, token, "/departments/"+url.PathEscape(id)+"/children", url.Values{"department_id_type": {"open_department_id"}, "page_size": {"1"}}, &ignored); err != nil {
result["department_access"] = "denied"
result["department_error"] = err.Error()
} else {
result["department_access"] = "ok"
}
if err := directoryGet(ctx, token, "/users", url.Values{"department_id": {id}, "department_id_type": {"open_department_id"}, "user_id_type": {"open_id"}, "page_size": {"1"}}, &ignored); err != nil {
result["user_list_access"] = "denied"
result["user_list_error"] = err.Error()
} else {
result["user_list_access"] = "ok"
}
return result, nil
}
func (p *Provider) DirectorySyncStatus(c *gin.Context) {
noStore(c)
if !secureAdmin(c) {
@@ -60,3 +60,46 @@ func TestReadDirectoryFailsClosedOnIncompletePage(t *testing.T) {
t.Fatalf("accepted partial directory: %v", err)
}
}
func TestDirectoryAccessCheckDistinguishesVisibilityAndEndpointPermission(t *testing.T) {
previous := upstreamClient
t.Cleanup(func() { upstreamClient = previous })
visible := false
calls := 0
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
calls++
body := `{"code":0,"data":{}}`
switch req.URL.Path {
case "/open-apis/auth/v3/tenant_access_token/internal":
body = `{"code":0,"tenant_access_token":"private-token"}`
case "/open-apis/contact/v3/scopes":
if req.Header.Get("Authorization") != "Bearer private-token" {
t.Fatal("missing scoped tenant token")
}
if visible {
body = `{"code":0,"data":{"department_ids":["0"],"user_ids":["ou_1"]}}`
} else {
body = `{"code":0,"data":{"department_ids":[],"user_ids":["ou_1"]}}`
}
case "/open-apis/contact/v3/departments/0/children":
body = `{"code":99991672,"msg":"private upstream message"}`
case "/open-apis/contact/v3/users":
body = `{"code":0,"data":{"items":[{"name":"private user"}]}}`
default:
t.Fatalf("unexpected endpoint: %s", req.URL)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})}
result, err := checkDirectoryAccess(context.Background(), "cli_test", "private-secret")
if err != nil || result["visible_departments"] != 0 || result["department_access"] != "unverified" || calls != 2 {
t.Fatalf("false permission conclusion for invisible departments: %v %v calls=%d", result, err, calls)
}
visible = true
result, err = checkDirectoryAccess(context.Background(), "cli_test", "private-secret")
if err != nil || result["department_access"] != "denied" || result["user_list_access"] != "ok" {
t.Fatalf("wrong endpoint probes: %v %v", result, err)
}
if raw := result["department_error"].(string); !strings.Contains(raw, "99991672") || strings.Contains(raw, "private upstream message") || strings.Contains(raw, "private-token") {
t.Fatalf("error leaked upstream data or omitted code: %s", raw)
}
}
+3
View File
@@ -22,6 +22,7 @@
- FR-02:先读取通讯录可见范围,逐个遍历部门子节点、分页读取各部门直属成员,按 open_id 去重;无法完成任何一页则本次不写入。
- FR-03:只在完整读取成功后批量更新导入表和状态;不删除缺席记录,避免范围缩小或临时失权导致误删。不得从导入结果自动给 VPN 授权。
- FR-04:页面显示同步时间、部门/成员数及失败提示,并列出导入的成员和部门供管理员核对。
- FR-05:飞书身份页突出提醒管理员发布应用,并将应用可用范围设为“全体员工”;打开页面即通过现有应用身份凭据检查实时通讯录可见范围,存在可见部门时探测部门子节点及成员列表权限。范围为空或接口被拒绝时分别提示,不能把接口可用误报为全员可见。
- NFR-01:限制单次运行时长、页数和条数,同一实例内互斥;外部错误不含密钥、token 或通讯录原始报文。
- NFR-02:仅管理权限、HTTPS 入口可读写导入信息;关闭自动同步不会删除已导入数据。
@@ -34,6 +35,7 @@
## 4. 接口、数据与配置契约
- 新增 `GET /api/system/identity/directory-sync`、`PUT /api/system/identity/directory-sync`(enabled)、`POST /api/system/identity/directory-sync/run`。仅 admin + HTTPS;不返回凭据。
- 新增 `GET /api/system/identity/directory-sync/check`,只返回 `/scopes` 的入口计数和部门/用户列表探测状态、受控错误码,不返回姓名、open_id 或 access token。可用范围“全体员工”和应用发布状态无法由这些接口直接证明,须在 UI 明确标为人工设置。
- 新增同步状态、部门、成员表,使用 `(issuer, app_id, id)` 区分应用;保留缺席成员,仅更新本次见到的数据和 `last_seen_at`。
- 最近同步状态还记录飞书 `/scopes` 返回的可见部门入口数和单独成员数;这不是企业通讯录总人数。
- 不修改原有登录、专线及 VPNUser API。Fly书权限以实际 API 报错为准:部门与成员列表通常要求部门和用户的应用身份读取权限及足够的通讯录可见范围,页面提供具体引导。
@@ -53,6 +55,7 @@
| 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`、远程 API 手动试读 | API 通过:1 名成员、0 个部门;页面待人工检查 |
| AC-05 | FR-05 | 打开身份页(当前部门入口 0,单独成员 1) | 红色提示全体员工/发布;自动显示范围不足且不误判部门接口权限 | `TestDirectoryAccessCheckDistinguishesVisibilityAndEndpointPermission`、前端构建、远程只读检查 | 本地测试和构建通过;远程待验证 |
2026-10-23 测试服务器部署 `594bb61` 后,手动同步成功,自动开关仍关闭。`/scopes` 实际返回可见部门入口 0 个、单独可见成员 1 位;成功导入成员 1 位、部门 0 个。这与用户截图中的企业通讯录 3 位成员不同,不能将可见范围误认为企业总人数。界面已增补范围计数与部门缺失提示;等待管理员在飞书开放平台配置部门权限、应用通讯录可见范围并发布后复测组织树。HTTPS 登录页返回 200、管理健康检查通过、OpenVPN 原进程持续运行。
+1 -1
View File
@@ -794,7 +794,7 @@
"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}", "visibility": "App-visible scope: {departments} department roots · {users} individually visible users (not the enterprise total)", "noDepartments": "Only individually visible members were returned. Grant department read access and expand the app's directory visibility in Feishu, publish, then sync again.", "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"
"title": "Feishu directory", "publishAll": "Publish the app in Feishu and set its availability to All Employees. Also grant directory visibility for the departments to import.", "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.", "liveCheck": "Live permission check", "recheck": "Check again", "checking": "Checking Feishu access and visibility…", "checkFailed": "Could not check Feishu permissions", "liveVisibility": "App API visibility: {departments} department roots, {users} individual users (not the enterprise total).", "scopeMissing": "No visible departments. Include departments in the app directory visibility and publish. Department and user-list permissions cannot yet be verified.", "permissionMissing": "Department endpoint: {department}; user list endpoint: {users}. Grant the required app identity scopes and publish.", "apiReady": "Department and user-list endpoints work. Verify All Employees and publication in the Feishu console.", "ok": "Available", "auto": "Auto sync (30 min)", "run": "Sync now", "lastSuccess": "Last success", "counts": "Departments {departments} · members {members}", "visibility": "Last sync scope: {departments} department roots · {users} individual users", "noDepartments": "Only individually visible members were returned. Grant department read access and expand the app's directory visibility in Feishu, publish, then sync again.", "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",
+1 -1
View File
@@ -794,7 +794,7 @@
"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}", "visibility": "飞书应用当前可见范围:部门入口 {departments} · 单独成员 {users}(与企业通讯录总人数不同)", "noDepartments": "当前只读到单独授权的成员,没有可见部门。请在飞书配置应用的部门读取权限和通讯录可见范围,发布后重新同步。", "name": "姓名", "departments": "部门 ID", "firstMembers": "仅显示前 500 位成员", "saved": "同步设置已保存", "started": "已开始同步,可查看下方进度", "done": "通讯录同步完成", "failed": "通讯录同步失败"
"title": "飞书组织与成员", "publishAll": "必须在飞书开放平台发布应用,并将应用可用范围改为“全体员工”;同时确保通讯录权限和可见范围覆盖需要同步的部门。", "hint": "手动导入,或开启后每 30 分钟同步一次。导入不会自动开通 VPN 或授予专线权限;仅显示飞书返回的姓名。", "permissions": "需应用身份读取部门及直属成员(如 contact:department.base:readonly、contact:user.department:readonly);发布应用,并设置通讯录可见范围。具体权限以飞书接口报错为准。", "liveCheck": "实时权限检查", "recheck": "重新检测", "checking": "正在检查飞书权限与可见范围…", "checkFailed": "飞书权限检查失败", "liveVisibility": "应用 API 可见:部门入口 {departments} 个,单独成员 {users} 位(不是企业总人数)。", "scopeMissing": "没有可见部门;请在飞书应用的通讯录权限管理中开放部门范围并发布。此时无法确认部门及直属成员接口是否有权限。", "permissionMissing": "部门接口:{department};成员列表接口:{users}。请按错误码补充应用身份权限并发布。", "apiReady": "部门与成员列表接口可用;“全体员工”和发布状态仍需在飞书开放平台核对。", "ok": "可用", "auto": "自动同步(30 分钟)", "run": "立即同步", "lastSuccess": "上次成功", "counts": "部门 {departments} · 成员 {members}", "visibility": "上次同步可见范围:部门入口 {departments} · 单独成员 {users}", "noDepartments": "当前只读到单独授权的成员,没有可见部门。请在飞书配置应用的部门读取权限和通讯录可见范围,发布后重新同步。", "name": "姓名", "departments": "部门 ID", "firstMembers": "仅显示前 500 位成员", "saved": "同步设置已保存", "started": "已开始同步,可查看下方进度", "done": "通讯录同步完成", "failed": "通讯录同步失败"
},
"feishuDirectory": {
"title": "飞书姓名查询",
@@ -9,6 +9,7 @@
<p>{{ $t('identity.guidePublish') }}</p>
<p>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></p>
</div>
<el-alert type="error" :closable="false" :title="$t('feishuSync.publishAll')" show-icon style="margin-bottom:16px" />
<el-alert v-if="!identitySecure" type="warning" :closable="false" :title="$t('identity.httpsOnly')" style="margin-bottom:16px" />
<el-button v-if="!identitySecure && identityHttpsUrl" type="primary" tag="a" :href="identityHttpsUrl" style="margin-bottom:16px">{{ $t('identity.openHttps') }}</el-button>
<el-form v-if="identitySecure" label-position="top" @submit.prevent="saveIdentity">
@@ -60,6 +61,20 @@
<template #header><b>{{ $t('feishuSync.title') }}</b></template>
<p class="tls-hint">{{ $t('feishuSync.hint') }}</p>
<p class="tls-hint">{{ $t('feishuSync.permissions') }}</p>
<div class="directory-check" style="align-items:center;margin:12px 0">
<b>{{ $t('feishuSync.liveCheck') }}</b>
<el-button :loading="scopeChecking" size="small" @click="checkScope">{{ $t('feishuSync.recheck') }}</el-button>
</div>
<el-alert v-if="scopeError" type="error" :closable="false" :title="scopeError" style="margin-bottom:12px" />
<el-alert v-else-if="scopeChecking" type="info" :closable="false" :title="$t('feishuSync.checking')" style="margin-bottom:12px" />
<template v-else-if="scopeResult">
<el-alert :type="scopeResult.visible_departments ? 'info' : 'error'" :closable="false"
:title="$t('feishuSync.liveVisibility', { departments: scopeResult.visible_departments, users: scopeResult.visible_users })" style="margin-bottom:12px" />
<el-alert v-if="!scopeResult.visible_departments" type="error" :closable="false" :title="$t('feishuSync.scopeMissing')" style="margin-bottom:12px" />
<el-alert v-else-if="scopeResult.department_access !== 'ok' || scopeResult.user_list_access !== 'ok'" type="error" :closable="false"
:title="$t('feishuSync.permissionMissing', { department: scopeResult.department_error || $t('feishuSync.ok'), users: scopeResult.user_list_error || $t('feishuSync.ok') })" style="margin-bottom:12px" />
<el-alert v-else type="success" :closable="false" :title="$t('feishuSync.apiReady')" style="margin-bottom:12px" />
</template>
<div class="directory-check">
<el-switch :model-value="syncStatus.enabled || false" :loading="syncSaving" :active-text="$t('feishuSync.auto')" @change="setSyncEnabled" />
<el-button :loading="syncRunning" @click="runSync">{{ $t('feishuSync.run') }}</el-button>
@@ -128,6 +143,10 @@ const syncError = ref('')
const syncLoading = ref(false)
const syncSaving = ref(false)
const syncRunning = ref(false)
const scopeResult = ref(null)
const scopeError = ref('')
const scopeChecking = ref(false)
let scopeRequest = 0
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)
@@ -135,7 +154,7 @@ const tenantSeconds = ref(0)
let tenantTimer
let identityCheckTimer
let tenantRequest = 0
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); clearTimeout(syncPollTimer); tenantRequest++ })
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); clearTimeout(syncPollTimer); tenantRequest++; scopeRequest++ })
watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => {
if (appId === oldId && secret === oldSecret) return
tenantRequest++
@@ -184,7 +203,8 @@ async function loadIdentity() {
try {
const { data } = await api.get('/system/identity')
identityStatus.value = data
if (data.enabled && identitySecure) { loadProfiles(); loadSync() }
if (data.enabled && identitySecure) { loadProfiles(); loadSync(); checkScope() }
else { scopeRequest++; scopeResult.value = null; scopeError.value = '' }
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
tenantName.value = ''
tenantError.value = ''
@@ -226,6 +246,18 @@ async function loadSync() {
} catch (e) { syncError.value = e.response?.data?.error || t('feishuSync.failed') }
finally { syncLoading.value = false }
}
async function checkScope() {
const request = ++scopeRequest
scopeChecking.value = true
scopeError.value = ''
scopeResult.value = null
try {
const { data } = await api.get('/system/identity/directory-sync/check')
if (request === scopeRequest) scopeResult.value = data
} catch (e) {
if (request === scopeRequest) scopeError.value = e.response?.data?.error || t('feishuSync.checkFailed')
} finally { if (request === scopeRequest) scopeChecking.value = false }
}
async function setSyncEnabled(enabled) {
syncSaving.value = true
try {