Files
larksuite-cli/internal/registry/scopes.go
梁硕 83dfb068ad feat: open-source lark-cli — the official CLI for Lark/Feishu
Change-Id: I113d9cdb5403cec347efe4595415e34a18b7decf
2026-03-28 10:36:25 +08:00

385 lines
9.9 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"sort"
"strings"
)
// IdentityToAccessToken maps the --identity flag value to the corresponding
// accessTokens value used in from_meta JSON files. Bot identity uses
// tenant_access_token, so "bot" maps to "tenant".
func IdentityToAccessToken(identity string) string {
if identity == "bot" {
return "tenant"
}
return identity
}
// FilterScopes filters scopes by domain and permission level.
func FilterScopes(allScopes []string, domains []string, permissions []string) []string {
var result []string
for _, scope := range allScopes {
parts := strings.Split(scope, ":")
if len(domains) > 0 {
if len(parts) == 0 {
continue
}
found := false
for _, d := range domains {
if parts[0] == d {
found = true
break
}
}
if !found {
continue
}
}
if len(permissions) > 0 {
if len(parts) < 3 {
continue
}
perm := parts[2]
matched := false
for _, p := range permissions {
switch p {
case "read":
if strings.Contains(perm, "read") {
matched = true
}
case "write":
if strings.Contains(perm, "write") {
matched = true
}
case "readonly":
if perm == "readonly" {
matched = true
}
case "writeonly":
if perm == "writeonly" || perm == "write_only" {
matched = true
}
}
}
if !matched {
continue
}
}
result = append(result, scope)
}
return result
}
// CollectScopesForProjects collects the recommended scope for each API method
// in the specified from_meta projects. For each method, only the scope with
// the highest priority score is selected.
func CollectScopesForProjects(projects []string, identity string) []string {
priorities := LoadScopePriorities()
scopeSet := make(map[string]bool)
for _, project := range projects {
spec := LoadFromMeta(project)
if spec == nil {
continue
}
resources, ok := spec["resources"].(map[string]interface{})
if !ok {
continue
}
for _, resSpec := range resources {
resMap, ok := resSpec.(map[string]interface{})
if !ok {
continue
}
methods, ok := resMap["methods"].(map[string]interface{})
if !ok {
continue
}
for _, methodSpec := range methods {
methodMap, ok := methodSpec.(map[string]interface{})
if !ok {
continue
}
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
supported := false
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
supported = true
break
}
}
if !supported {
continue
}
}
scopes, ok := methodMap["scopes"].([]interface{})
if !ok || len(scopes) == 0 {
continue
}
bestScope := ""
bestScore := -1
for _, s := range scopes {
str, ok := s.(string)
if !ok {
continue
}
score := DefaultScopeScore
if v, exists := priorities[str]; exists {
score = v
}
if score > bestScore {
bestScore = score
bestScope = str
}
}
if bestScope != "" {
scopeSet[bestScope] = true
}
}
}
}
result := make([]string, 0, len(scopeSet))
for s := range scopeSet {
result = append(result, s)
}
sort.Strings(result)
return result
}
// ScopeSource tracks which APIs and shortcuts contributed a scope.
type ScopeSource struct {
APIs []string // e.g. "POST calendar.event.create"
Shortcuts []string // e.g. "+send", "+reply"
}
// CollectScopesWithSources is like CollectScopesForProjects but also records
// which API method contributed each scope. Used by scope-audit.
func CollectScopesWithSources(projects []string, identity string) ([]string, map[string]*ScopeSource) {
priorities := LoadScopePriorities()
scopeSet := make(map[string]bool)
sources := make(map[string]*ScopeSource)
for _, project := range projects {
spec := LoadFromMeta(project)
if spec == nil {
continue
}
resources, ok := spec["resources"].(map[string]interface{})
if !ok {
continue
}
for resName, resSpec := range resources {
resMap, ok := resSpec.(map[string]interface{})
if !ok {
continue
}
methods, ok := resMap["methods"].(map[string]interface{})
if !ok {
continue
}
for methodName, methodSpec := range methods {
methodMap, ok := methodSpec.(map[string]interface{})
if !ok {
continue
}
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
supported := false
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
supported = true
break
}
}
if !supported {
continue
}
}
scopes, ok := methodMap["scopes"].([]interface{})
if !ok || len(scopes) == 0 {
continue
}
bestScope := ""
bestScore := -1
for _, s := range scopes {
str, ok := s.(string)
if !ok {
continue
}
score := DefaultScopeScore
if v, exists := priorities[str]; exists {
score = v
}
if score > bestScore {
bestScore = score
bestScope = str
}
}
if bestScope != "" {
scopeSet[bestScope] = true
if sources[bestScope] == nil {
sources[bestScope] = &ScopeSource{}
}
methodID := GetStrFromMap(methodMap, "id")
if methodID == "" {
methodID = project + "." + resName + "." + methodName
}
httpMethod := GetStrFromMap(methodMap, "httpMethod")
if httpMethod == "" {
httpMethod = "?"
}
sources[bestScope].APIs = append(sources[bestScope].APIs, httpMethod+" "+methodID)
}
}
}
}
// Sort API lists for stable output
for _, src := range sources {
sort.Strings(src.APIs)
}
result := make([]string, 0, len(scopeSet))
for s := range scopeSet {
result = append(result, s)
}
sort.Strings(result)
return result, sources
}
// CommandEntry represents a CLI command (API method or shortcut) and its scopes.
type CommandEntry struct {
Command string // CLI label, e.g. "calendars create" or "+agenda"
Type string // "api" or "shortcut"
Scopes []string // effective scopes (requiredScopes if present, else [bestScope])
HTTPMethod string // e.g. "POST" (API only)
}
// CollectCommandScopes walks from_meta methods for the given projects and
// returns one CommandEntry per API method, sorted by command label.
//
// Scope selection per method:
// - If the method has a "requiredScopes" field, all of those scopes are needed (conjunction).
// - Otherwise, only the highest-priority scope from "scopes" is shown (minimum privilege).
func CollectCommandScopes(projects []string, identity string) []CommandEntry {
priorities := LoadScopePriorities()
var entries []CommandEntry
for _, project := range projects {
spec := LoadFromMeta(project)
if spec == nil {
continue
}
resources, ok := spec["resources"].(map[string]interface{})
if !ok {
continue
}
for resName, resSpec := range resources {
resMap, ok := resSpec.(map[string]interface{})
if !ok {
continue
}
methods, ok := resMap["methods"].(map[string]interface{})
if !ok {
continue
}
for methodName, methodSpec := range methods {
methodMap, ok := methodSpec.(map[string]interface{})
if !ok {
continue
}
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
supported := false
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
supported = true
break
}
}
if !supported {
continue
}
}
rawScopes, ok := methodMap["scopes"].([]interface{})
if !ok || len(rawScopes) == 0 {
continue
}
// Check for requiredScopes (conjunction — all needed)
var effectiveScopes []string
if reqRaw, ok := methodMap["requiredScopes"].([]interface{}); ok && len(reqRaw) > 0 {
for _, s := range reqRaw {
if str, ok := s.(string); ok {
effectiveScopes = append(effectiveScopes, str)
}
}
} else {
// Pick the single best scope (minimum privilege)
bestScope := ""
bestScore := -1
for _, s := range rawScopes {
str, ok := s.(string)
if !ok {
continue
}
score := DefaultScopeScore
if v, exists := priorities[str]; exists {
score = v
}
if score > bestScore {
bestScore = score
bestScope = str
}
}
if bestScope != "" {
effectiveScopes = []string{bestScope}
}
}
if len(effectiveScopes) == 0 {
continue
}
httpMethod := GetStrFromMap(methodMap, "httpMethod")
entries = append(entries, CommandEntry{
Command: resName + " " + methodName,
Type: "api",
Scopes: effectiveScopes,
HTTPMethod: httpMethod,
})
}
}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Command < entries[j].Command
})
return entries
}
// GetScopesForDomains returns scopes for specific projects (by project name).
func GetScopesForDomains(projects []string, identity string) []string {
return CollectScopesForProjects(projects, identity)
}
// GetReadOnlyScopes returns read-only scopes from the recommended (best-per-method) scope set.
func GetReadOnlyScopes(identity string) []string {
allProjects := ListFromMetaProjects()
return FilterScopes(CollectScopesForProjects(allProjects, identity), nil, []string{"read", "readonly"})
}
// ResolveScopesFromFilters resolves scopes from project and permission filters.
func ResolveScopesFromFilters(projects []string, permissions []string, identity string) []string {
return FilterScopes(CollectScopesForProjects(projects, identity), nil, permissions)
}
// ComputeMinimumScopeSet computes the minimum set of scopes that covers all
// from_meta API methods. Equivalent to CollectScopesForProjects with all projects.
func ComputeMinimumScopeSet(identity string) []string {
return CollectScopesForProjects(ListFromMetaProjects(), identity)
}