Compare commits

...

1 Commits

Author SHA1 Message Date
jiaxing.04
db47ea0f47 feat(drive): add +public-permission-update shortcut
Add drive +public-permission-update for public permission changes, including single_page support for link-share and external-access settings.

Key features:

- Support --perm-type single_page for the public permission update command

- Validate allowed field combinations before API calls

- Cover the command with unit tests, dry-run E2E coverage, and lark-drive skill docs
2026-06-30 20:59:44 +08:00
7 changed files with 907 additions and 1 deletions

View File

@@ -0,0 +1,287 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const drivePublicPermissionScope = "docs:permission.setting:write_only"
var drivePublicPermissionTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides",
}
var drivePublicPermissionURLPathToType = []struct {
Prefix string
Type string
}{
{"/mindnotes/", "mindnote"},
{"/bitable/", "bitable"},
{"/sheets/", "sheet"},
{"/minutes/", "minutes"},
{"/slides/", "slides"},
{"/docx/", "docx"},
{"/wiki/", "wiki"},
{"/base/", "bitable"},
{"/file/", "file"},
{"/doc/", "doc"},
}
var (
drivePublicPermissionSecurityEntities = []string{"anyone_can_view", "anyone_can_edit", "only_full_access"}
drivePublicPermissionCommentEntities = []string{"anyone_can_view", "anyone_can_edit"}
drivePublicPermissionShareEntities = []string{"anyone", "same_tenant"}
drivePublicPermissionManageEntities = []string{"collaborator_can_view", "collaborator_can_edit", "collaborator_full_access"}
drivePublicPermissionLinkEntities = []string{
"tenant_readable", "tenant_editable",
"anyone_readable", "anyone_editable",
"partner_tenant_readable", "partner_tenant_editable",
"closed",
}
drivePublicPermissionCopyEntities = drivePublicPermissionSecurityEntities
drivePublicPermissionExternalEntities = []string{"open", "closed", "allow_share_partner_tenant"}
drivePublicPermissionPermTypes = []string{"container", "single_page"}
)
var drivePublicPermissionBodyFlags = []struct {
Flag string
JSON string
}{
{"security-entity", "security_entity"},
{"comment-entity", "comment_entity"},
{"share-entity", "share_entity"},
{"manage-collaborator-entity", "manage_collaborator_entity"},
{"link-share-entity", "link_share_entity"},
{"copy-entity", "copy_entity"},
{"external-access-entity", "external_access_entity"},
}
// DrivePublicPermissionUpdate updates public permission settings using the
// drive/v2 public-permission PATCH endpoint.
var DrivePublicPermissionUpdate = common.Shortcut{
Service: "drive",
Command: "+public-permission-update",
Description: "Update public permission settings on a Drive document or file",
Risk: "high-risk-write",
Scopes: []string{drivePublicPermissionScope},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnotes/minutes/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: drivePublicPermissionTypes},
{Name: "security-entity", Desc: "who can copy, duplicate, print, and download", Enum: drivePublicPermissionSecurityEntities},
{Name: "comment-entity", Desc: "who can comment", Enum: drivePublicPermissionCommentEntities},
{Name: "share-entity", Desc: "who can view, add, or remove collaborators at the org level", Enum: drivePublicPermissionShareEntities},
{Name: "manage-collaborator-entity", Desc: "who can manage collaborators", Enum: drivePublicPermissionManageEntities},
{Name: "link-share-entity", Desc: "link sharing setting", Enum: drivePublicPermissionLinkEntities},
{Name: "copy-entity", Desc: "who can create copies", Enum: drivePublicPermissionCopyEntities},
{Name: "external-access-entity", Desc: "external sharing setting", Enum: drivePublicPermissionExternalEntities},
{Name: "perm-type", Desc: "permission scope for link/external access changes", Enum: drivePublicPermissionPermTypes},
},
Tips: []string{
"Calls PATCH /open-apis/drive/v2/permissions/:token/public; use --dry-run first to inspect the exact body.",
"This is a high-risk write because public permission changes can expose or restrict document access; pass --yes only after confirming the target and fields.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return err
}
if err := validateDrivePublicPermissionBody(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, docType, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDrivePublicPermissionBody(runtime); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Update Drive public permission settings").
PATCH("/open-apis/drive/v2/permissions/:token/public").
Params(map[string]interface{}{"type": docType}).
Body(buildDrivePublicPermissionBody(runtime)).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, docType, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return err
}
body := buildDrivePublicPermissionBody(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Updating public permission settings on %s %s...\n",
docType, common.MaskToken(token))
data, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
func resolveDrivePublicPermissionTarget(raw, explicitType string) (resourceID, docType string, err error) {
raw = strings.TrimSpace(raw)
explicitType = strings.TrimSpace(explicitType)
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
if strings.Contains(raw, "://") {
parsed, parseErr := url.Parse(raw)
if parseErr != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid URL %q: %v",
raw,
parseErr,
).WithParam("--token").WithCause(parseErr)
}
var urlType string
var ok bool
resourceID, urlType, ok = parseDrivePublicPermissionURLPath(parsed.Path)
if resourceID == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnotes/, /minutes/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
).WithParam("--token")
}
if ok && explicitType == "" {
docType = urlType
}
} else {
resourceID = raw
}
if explicitType != "" {
docType = explicitType
}
if docType == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(drivePublicPermissionTypes, ", "),
).WithParam("--type")
}
if err := validate.ResourceName(resourceID, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token").WithCause(err)
}
return resourceID, docType, nil
}
func parseDrivePublicPermissionURLPath(path string) (resourceID, docType string, ok bool) {
for _, mapping := range drivePublicPermissionURLPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
}
candidate := path[len(mapping.Prefix):]
candidate = strings.TrimRight(candidate, "/")
if idx := strings.IndexByte(candidate, '/'); idx >= 0 {
candidate = candidate[:idx]
}
candidate = strings.TrimSpace(candidate)
if candidate == "" {
return "", "", false
}
return candidate, mapping.Type, true
}
return "", "", false
}
func validateDrivePublicPermissionBody(runtime *common.RuntimeContext) error {
changedPermissionFields := 0
for _, f := range drivePublicPermissionBodyFlags {
if strings.TrimSpace(runtime.Str(f.Flag)) != "" {
changedPermissionFields++
}
}
if changedPermissionFields == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"nothing to update: specify at least one of --security-entity, --comment-entity, --share-entity, --manage-collaborator-entity, --link-share-entity, --copy-entity, or --external-access-entity",
)
}
if err := validateExternalLinkShareCombo(
strings.TrimSpace(runtime.Str("external-access-entity")),
strings.TrimSpace(runtime.Str("link-share-entity")),
); err != nil {
return err
}
if runtime.Str("perm-type") == "single_page" {
hasLinkOrExternal := strings.TrimSpace(runtime.Str("link-share-entity")) != "" ||
strings.TrimSpace(runtime.Str("external-access-entity")) != ""
if !hasLinkOrExternal {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--perm-type single_page is only supported with --link-share-entity or --external-access-entity",
).WithParam("--perm-type")
}
for _, flag := range []string{"security-entity", "comment-entity", "share-entity", "manage-collaborator-entity", "copy-entity"} {
if strings.TrimSpace(runtime.Str(flag)) != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--perm-type single_page only supports --link-share-entity and --external-access-entity; remove --%s",
flag,
).WithParam("--perm-type")
}
}
}
return nil
}
// validateExternalLinkShareCombo checks that link_share_entity does not exceed
// the external sharing boundary set by external_access_entity.
//
// external=open → ok: anyone_*, partner_tenant_*, tenant_*, closed
// external=allow_share_partner_tenant → ok: partner_tenant_*, tenant_*, closed | conflict: anyone_*
// external=closed → ok: tenant_*, closed | conflict: anyone_*, partner_tenant_*
func validateExternalLinkShareCombo(external, linkShare string) error {
if external == "" || linkShare == "" {
return nil
}
switch external {
case "open":
case "allow_share_partner_tenant":
if strings.HasPrefix(linkShare, "anyone_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--link-share-entity %q conflicts with --external-access-entity allow_share_partner_tenant: anyone_* requires external=open",
linkShare,
).WithParam("--link-share-entity")
}
case "closed":
if strings.HasPrefix(linkShare, "anyone_") || strings.HasPrefix(linkShare, "partner_tenant_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--link-share-entity %q conflicts with --external-access-entity closed: only tenant_* or closed are allowed",
linkShare,
).WithParam("--link-share-entity")
}
}
return nil
}
func buildDrivePublicPermissionBody(runtime *common.RuntimeContext) map[string]interface{} {
body := make(map[string]interface{})
for _, f := range drivePublicPermissionBodyFlags {
if value := strings.TrimSpace(runtime.Str(f.Flag)); value != "" {
body[f.JSON] = value
}
}
if value := strings.TrimSpace(runtime.Str("perm-type")); value != "" {
body["perm_type"] = value
}
return body
}

View File

@@ -0,0 +1,329 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestResolveDrivePublicPermissionTarget_BareTokenNeedsType(t *testing.T) {
t.Parallel()
_, _, err := resolveDrivePublicPermissionTarget("doxTok123", "")
assertDrivePublicPermissionValidationError(t, err, "--type", "--type is required")
}
func TestResolveDrivePublicPermissionTarget_URLInference(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
wantTok string
wantType string
}{
{"docx", "https://example.feishu.cn/docx/doxTok123?from=share", "doxTok123", "docx"},
{"docx trailing path", "https://example.feishu.cn/docx/doxTok123/extra/path?from=share", "doxTok123", "docx"},
{"sheet", "https://example.feishu.cn/sheets/shtTok456?sheet=abc", "shtTok456", "sheet"},
{"base", "https://example.feishu.cn/base/bscTok789", "bscTok789", "bitable"},
{"file", "https://example.feishu.cn/file/boxTok111", "boxTok111", "file"},
{"wiki", "https://example.feishu.cn/wiki/wikTok222", "wikTok222", "wiki"},
{"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"},
{"mindnote", "https://example.feishu.cn/mindnotes/mnTok444", "mnTok444", "mindnote"},
{"minutes", "https://example.feishu.cn/minutes/obcnTok555", "obcnTok555", "minutes"},
{"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
token, docType, err := resolveDrivePublicPermissionTarget(tt.raw, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != tt.wantTok || docType != tt.wantType {
t.Fatalf("got target (%q, %q), want (%q, %q)", token, docType, tt.wantTok, tt.wantType)
}
})
}
}
func TestResolveDrivePublicPermissionTarget_ExplicitTypeOverridesURL(t *testing.T) {
t.Parallel()
token, docType, err := resolveDrivePublicPermissionTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "doxTok123" || docType != "wiki" {
t.Fatalf("got target (%q, %q), want (doxTok123, wiki)", token, docType)
}
}
func TestResolveDrivePublicPermissionTarget_RejectsMarkerOutsidePath(t *testing.T) {
t.Parallel()
tests := []string{
"https://example.feishu.cn/share?redirect=/docx/doxTok123",
"https://example.feishu.cn/share#/docx/doxTok123",
"https://example.feishu.cn/space/docx/doxTok123",
"https://example.feishu.cn/foo/bitable/bscTok789",
}
for _, raw := range tests {
t.Run(raw, func(t *testing.T) {
t.Parallel()
_, _, err := resolveDrivePublicPermissionTarget(raw, "")
assertDrivePublicPermissionValidationError(t, err, "--token", "could not infer token from URL")
})
}
}
func TestDrivePublicPermissionUpdate_DryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
"--external-access-entity", "open",
"--link-share-entity", "anyone_readable",
"--perm-type", "single_page",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/drive/v2/permissions/doxTok123/public",
`"PATCH"`,
`"type": "docx"`,
`"external_access_entity": "open"`,
`"link_share_entity": "anyone_readable"`,
`"perm_type": "single_page"`,
`"` + "to" + `ken": "doxTok123"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDrivePublicPermissionUpdate_ValidateRejectsNoBodyFields(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "", "nothing to update")
}
func TestDrivePublicPermissionUpdate_ValidateRejectsPermTypeOnly(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--perm-type", "single_page",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "", "nothing to update")
}
func TestDrivePublicPermissionUpdate_ValidateRejectsSinglePageForUnsupportedField(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--security-entity", "only_full_access",
"--perm-type", "single_page",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "--perm-type", "--perm-type single_page")
}
func TestDrivePublicPermissionUpdate_ValidateRejectsSinglePageMixedFields(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--link-share-entity", "closed",
"--copy-entity", "only_full_access",
"--perm-type", "single_page",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "--perm-type", "remove --copy-entity")
}
func TestValidateExternalLinkShareCombo(t *testing.T) {
t.Parallel()
tests := []struct {
name string
external string
link string
wantErr bool
contains string
}{
// Both empty: no validation needed.
{"both empty", "", "", false, ""},
// Only one set: no combo to check.
{"only external", "open", "", false, ""},
{"only link", "", "anyone_readable", false, ""},
// external=open
{"open + anyone_readable", "open", "anyone_readable", false, ""},
{"open + tenant_readable", "open", "tenant_readable", false, ""},
{"open + closed", "open", "closed", false, ""},
{"open + partner_tenant_readable", "open", "partner_tenant_readable", false, ""},
// external=allow_share_partner_tenant
{"partner + partner_tenant_readable", "allow_share_partner_tenant", "partner_tenant_readable", false, ""},
{"partner + tenant_readable", "allow_share_partner_tenant", "tenant_readable", false, ""},
{"partner + closed", "allow_share_partner_tenant", "closed", false, ""},
{"partner + anyone_readable", "allow_share_partner_tenant", "anyone_readable", true, "anyone_* requires external=open"},
// external=closed
{"closed + tenant_readable", "closed", "tenant_readable", false, ""},
{"closed + closed", "closed", "closed", false, ""},
{"closed + anyone_readable", "closed", "anyone_readable", true, "only tenant_* or closed are allowed"},
{"closed + partner_tenant_readable", "closed", "partner_tenant_readable", true, "only tenant_* or closed are allowed"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateExternalLinkShareCombo(tt.external, tt.link)
if tt.wantErr {
assertDrivePublicPermissionValidationError(t, err, "--link-share-entity", tt.contains)
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
})
}
}
func TestDrivePublicPermissionUpdate_ValidateRejectsExternalLinkCombo(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--external-access-entity", "closed",
"--link-share-entity", "anyone_readable",
"--as", "user",
}, f, stdout)
assertDrivePublicPermissionValidationError(t, err, "--link-share-entity", "only tenant_* or closed are allowed")
}
func TestDrivePublicPermissionUpdate_HighRiskRequiresYes(t *testing.T) {
t.Parallel()
if DrivePublicPermissionUpdate.Risk != "high-risk-write" {
t.Fatalf("Risk = %q, want high-risk-write", DrivePublicPermissionUpdate.Risk)
}
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--link-share-entity", "closed",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation error, got: %v", err)
}
}
func TestDrivePublicPermissionUpdate_ExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/permissions/doxTok123/public?type=docx",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
"+public-permission-update",
"--token", "doxTok123",
"--type", "docx",
"--link-share-entity", "closed",
"--copy-entity", "only_full_access",
"--yes",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["link_share_entity"] != "closed" || body["copy_entity"] != "only_full_access" {
t.Fatalf("unexpected request body: %#v", body)
}
if _, ok := body["permission_public"]; ok {
t.Fatalf("body must be flat, got nested permission_public: %#v", body)
}
}
func assertDrivePublicPermissionValidationError(t *testing.T, err error, wantParam, wantMessage string) {
t.Helper()
if err == nil {
t.Fatalf("expected validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q; err=%v", p.Category, errs.CategoryValidation, err)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q; err=%v", p.Subtype, errs.SubtypeInvalidArgument, err)
}
if wantMessage != "" && !strings.Contains(err.Error(), wantMessage) {
t.Fatalf("error %q does not contain %q", err.Error(), wantMessage)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if wantParam != "" && validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q; err=%v", validationErr.Param, wantParam, err)
}
}

View File

@@ -31,6 +31,7 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DrivePublicPermissionUpdate,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

@@ -34,6 +34,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+task_result",
"+apply-permission",
"+member-add",
"+public-permission-update",
"+secure-label-list",
"+secure-label-update",
"+search",

View File

@@ -32,6 +32,7 @@ metadata:
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要修改文档公开权限设置(链接分享、对外分享、谁可评论/复制/管理协作者),优先使用 `lark-cli drive +public-permission-update`,并先阅读 [`references/lark-drive-public-permission-update.md`](references/lark-drive-public-permission-update.md)。这是 `high-risk-write`,执行前必须 `--dry-run`,真正执行需 `--yes`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`
@@ -105,7 +106,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
### 权限能力入口
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch``91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
- 用户要管理 Drive 文档/文件协作者、授权当前应用访问文档,或做权限治理 / 排障时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md);如果是修改公开权限设置(链接分享、对外分享、谁可评论 / 复制 / 管理协作者),仍按快速决策走 [`lark-drive-public-permission-update.md`](references/lark-drive-public-permission-update.md)。
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
@@ -148,6 +149,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical tokenwiki URL 会自动解包到底层文档。 |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create真实写入需要 `--yes`。 |
| [`+public-permission-update`](references/lark-drive-public-permission-update.md) | 更新公开权限设置。高风险写操作:先 `--dry-run`,确认目标和字段后再传 `--yes`。 |
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |

View File

@@ -0,0 +1,111 @@
# drive +public-permission-update更新公开权限设置
本 skill 对应 shortcut`lark-cli drive +public-permission-update`
用于更新云文档/云文件的公开权限设置。
> [!CAUTION]
> 这是 `high-risk-write` 操作,会改变文档公开访问或协作边界。必须先用 `--dry-run` 确认请求体;真正执行时需要 `--yes`。
## 何时使用
- 用户明确要求修改“链接分享”“对外分享”“谁可以评论/复制/下载/管理协作者”等公开权限设置。
- 用户提供文档 URL 或 token并且已经确认要修改目标文档的权限策略。
不要用它来“申请自己访问文档”;申请权限走 [`drive +apply-permission`](lark-drive-apply-permission.md)。
## 身份与权限
- 支持 `--as user``--as bot`
- 所需 scope`docs:permission.setting:write_only`
- `--type` 会作为 query 参数传给接口。URL 输入可自动推断bare token 必须显式传。
## 常用命令
```bash
# 先预览:关闭对外分享,并关闭链接分享
lark-cli drive +public-permission-update \
--token "https://example.feishu.cn/docx/doxcnxxxxxxxxx" \
--external-access-entity closed \
--link-share-entity closed \
--dry-run --as user
# 真正执行:确认 target 和 body 后加 --yes
lark-cli drive +public-permission-update \
--token "https://example.feishu.cn/docx/doxcnxxxxxxxxx" \
--external-access-entity closed \
--link-share-entity closed \
--yes --as user
# 使用 bare token 时必须显式传 --type
lark-cli drive +public-permission-update \
--token "doxcnxxxxxxxxx" --type docx \
--external-access-entity closed \
--link-share-entity closed \
--yes --as user
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | 目标 token 或完整 URL。支持 `/docx/``/sheets/``/base/``/bitable/``/file/``/wiki/``/doc/``/mindnotes/``/minutes/``/slides/` 路径自动提取 token |
| `--type` | URL 可省略bare token 必填 | 目标类型:`doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides` |
| `--security-entity` | 否 | 谁可以复制内容、创建副本、打印、下载:`anyone_can_view``anyone_can_edit``only_full_access` |
| `--comment-entity` | 否 | 谁可以评论:`anyone_can_view``anyone_can_edit` |
| `--share-entity` | 否 | 从组织维度,设置谁可以查看、添加、移除协作者:`anyone``same_tenant` |
| `--manage-collaborator-entity` | 否 | 谁可以管理协作者:`collaborator_can_view``collaborator_can_edit``collaborator_full_access` |
| `--link-share-entity` | 否 | 链接分享设置:`tenant_readable``tenant_editable``anyone_readable``anyone_editable``partner_tenant_readable``partner_tenant_editable``closed` |
| `--copy-entity` | 否 | 谁可以创建副本:`anyone_can_view``anyone_can_edit``only_full_access` |
| `--external-access-entity` | 否 | 对外分享设置:`open``closed``allow_share_partner_tenant` |
| `--perm-type` | 否 | 权限范围:`container``single_page``single_page` 仅支持 `--link-share-entity` 和/或 `--external-access-entity`,不能混用其它权限字段 |
| `--dry-run` | 否 | 只打印请求,不执行 |
| `--yes` | 执行时必填 | 确认 high-risk-write 操作 |
至少要指定一个实际权限字段:`--security-entity``--comment-entity``--share-entity``--manage-collaborator-entity``--link-share-entity``--copy-entity``--external-access-entity`。单独传 `--perm-type` 会被拒绝。
## 请求体形状
```json
{
"external_access_entity": "closed",
"link_share_entity": "closed"
}
```
## Wiki URL
传入 `/wiki/<node_token>`shortcut 会以 `type=wiki` 直接调用公开权限接口。如果你要修改 wiki 背后的实际 docx/sheet/bitable 对象,先用 `drive +inspect``wiki spaces get_node` 拿到底层 `obj_token``obj_type`,再用 bare token + `--type <obj_type>` 调用。
## 常见错误
| 错误码 | 含义 | 引导 |
|--------|------|------|
| `1063001` | 参数异常 | 检查 token 和 `--type` 是否匹配、资源是否存在、字段枚举是否为 v2 文档支持值。`--external-access-entity``--link-share-entity` 同时传时可能出现参数冲突;`--perm-type single_page` 也有字段限制 |
| `1063002` | 权限不足 | 确认当前 user 或 bot 是目标文档协作者并具备编辑或管理权限bot 场景需要先给文档添加应用权限 |
| `1063003` | 操作不被允许 | 通常是企业策略、可见性、协作者上限或已有权限更高导致;不要简单提示补 scope |
| `1063004` | 用户无分享权限 | 确认调用身份对目标文档有分享权限 |
| `1063005` | 资源已删除 | 确认目标云文档仍存在 |
### 策略 / 密级拦截错误
调用 `lark-cli drive +public-permission-update` 返回以下错误码时,优先按租户策略、对外分享开关或文档密级处理,不要简单提示补 scope。
| 错误码 | 含义 | 引导 |
|--------|------|------|
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过当前命令或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
| `91010` | 文档对外分享未打开 | 按用户目标分流,不要扩大变更面:只关闭对外分享时仅传 `--external-access-entity closed`;只关闭链接分享时仅传 `--link-share-entity closed`;只有用户明确要求彻底关闭公开访问时,才同时传 `--external-access-entity closed --link-share-entity closed`。只有用户明确要求开放外部访问时,才提示先在文档权限设置中打开对外分享并确认风险后重试。 |
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
遇到 `91011``91012` 时,如果用户最初提供的是文档 URL直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL再给出可点击的文档 URL。
### 服务端运行时错误引导
以下场景 CLI 侧无法静态校验(依赖当前文档状态),需要根据服务端返回的错误信息引导用户:
**link_share 与当前 external 冲突:** 如果只传了 `--link-share-entity` 没传 `--external-access-entity`,服务端会用当前文档的 external 状态校验 link_share 是否合法。例如当前文档 external=closed设置 link_share=anyone_readable 会被拒绝。按用户目标分流:如果目标是收紧或关闭公开访问,改为同时传 `--external-access-entity closed``--link-share-entity closed`;只有用户明确要求互联网或外部可访问时,才提示先确认风险,再同时传 `--external-access-entity open` 和对应的 `--link-share-entity`。不要把这类冲突默认解释为需要打开对外分享。
**单页面独立权限:** `--perm-type single_page` 只适用于有 container 的文档会只修改当前页面的链接分享或对外分享设置legacy doc 不支持该能力,会被服务端按参数错误拒绝。单页面权限可以与容器权限不同;服务端返回或查询结果中的 `lock_switch=true` 表示当前页面已限制权限、不再继承父级页面权限。
遇到企业策略、对外分享或密级拦截时,不要把它们简单归类成缺少 scope应引导用户检查租户安全策略和文档权限设置。

View File

@@ -0,0 +1,175 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_PublicPermissionUpdateDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURL string
wantType string
assert func(t *testing.T, out string)
}{
{
name: "URL input infers type and sends v2 fields",
args: []string{
"drive", "+public-permission-update",
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
"--external-access-entity", "open",
"--link-share-entity", "anyone_readable",
"--perm-type", "single_page",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/doxcnE2E001/public",
wantType: "docx",
assert: func(t *testing.T, out string) {
if got := gjson.Get(out, "api.0.body.external_access_entity").String(); got != "open" {
t.Fatalf("body.external_access_entity = %q, want open\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.link_share_entity").String(); got != "anyone_readable" {
t.Fatalf("body.link_share_entity = %q, want anyone_readable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.perm_type").String(); got != "single_page" {
t.Fatalf("body.perm_type = %q, want single_page\nstdout:\n%s", got, out)
}
},
},
{
name: "bare token requires explicit type and sends flat body",
args: []string{
"drive", "+public-permission-update",
"--token", "shtcnE2E002",
"--type", "sheet",
"--security-entity", "only_full_access",
"--comment-entity", "anyone_can_edit",
"--manage-collaborator-entity", "collaborator_full_access",
"--copy-entity", "anyone_can_view",
"--dry-run",
},
wantURL: "/open-apis/drive/v2/permissions/shtcnE2E002/public",
wantType: "sheet",
assert: func(t *testing.T, out string) {
if got := gjson.Get(out, "api.0.body.security_entity").String(); got != "only_full_access" {
t.Fatalf("body.security_entity = %q, want only_full_access\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.comment_entity").String(); got != "anyone_can_edit" {
t.Fatalf("body.comment_entity = %q, want anyone_can_edit\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.permission_public").Exists() {
t.Fatalf("body must be flat, stdout:\n%s", out)
}
},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "PATCH" {
t.Fatalf("method = %q, want PATCH\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
}
tt.assert(t, out)
})
}
}
func TestDrive_PublicPermissionUpdateDryRunRejectsMissingTypeForBareToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+public-permission-update",
"--token", "doxcnE2E999",
"--link-share-entity", "closed",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
requireDrivePublicPermissionValidationEnvelope(t, result, "--type", "--type is required")
}
func TestDrive_PublicPermissionUpdateDryRunRejectsSinglePageMixedFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+public-permission-update",
"--token", "doxcnE2E999",
"--type", "docx",
"--link-share-entity", "closed",
"--copy-entity", "only_full_access",
"--perm-type", "single_page",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
requireDrivePublicPermissionValidationEnvelope(t, result, "--perm-type", "remove --copy-entity")
}
func requireDrivePublicPermissionValidationEnvelope(t *testing.T, result *clie2e.Result, wantParam, wantMessage string) {
t.Helper()
if result.ExitCode == 0 {
t.Fatalf("command must be rejected, stdout:\n%s", result.Stdout)
}
if got := gjson.Get(result.Stderr, "error.type").String(); got != "validation" {
t.Fatalf("error.type = %q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.subtype").String(); got != "invalid_argument" {
t.Fatalf("error.subtype = %q, want invalid_argument\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.param").String(); got != wantParam {
t.Fatalf("error.param = %q, want %q\nstdout:\n%s\nstderr:\n%s", got, wantParam, result.Stdout, result.Stderr)
}
message := gjson.Get(result.Stderr, "error.message").String()
if !strings.Contains(message, wantMessage) {
t.Fatalf("error.message %q does not contain %q\nstdout:\n%s\nstderr:\n%s", message, wantMessage, result.Stdout, result.Stderr)
}
}