Check Feishu directory scope and highlight app availability
This commit is contained in:
1 parent
bf9e1c3c2c
commit
e2597719c8
7 files changed
+147
-4
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user