Compare commits

...

1 Commits

Author SHA1 Message Date
jiaxing.04
162e896efc feat(drive): add +member-remove shortcut
Add drive +member-remove for removing one collaborator permission from Drive documents, files, folders, and wiki nodes. The shortcut validates resource and member contracts before issuing the high-risk DELETE request, preserves structured CLI errors, and documents identity and wiki permission behavior.

Key features:

- Resolve resource type from supported URLs or require it for bare tokens

- Support user and bot identities with member-type-specific validation

- Require explicit confirmation and return stable removal metadata

- Reject unsupported slash-containing tokens and member IDs before API calls

- Add unit, dry-run E2E, live workflow, and skill documentation coverage
2026-07-22 22:46:15 +08:00
8 changed files with 1077 additions and 0 deletions

View File

@@ -0,0 +1,266 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveMemberRemoveIDTypes = []string{
"email", "openid", "openchat", "opendepartmentid",
"userid", "unionid", "groupid", "wikispaceid",
}
// DriveMemberRemove removes one collaborator/member permission from a Drive resource.
var DriveMemberRemove = common.Shortcut{
Service: "drive",
Command: "+member-remove",
Description: "Remove one collaborator/member permission from a Drive document, file, folder, or wiki node",
Risk: "high-risk-write",
Scopes: []string{"docs:permission.member:delete"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "token", Desc: "target token or document URL; type is auto-inferred from URL path when omitted", Required: true},
{Name: "type", Desc: "target resource type; required when --token is a bare token"},
{Name: "member-id", Desc: "single collaborator ID to remove; comma-separated values are rejected", Required: true},
{Name: "member-type", Desc: "ID type for --member-id; supported: email|openid|openchat|opendepartmentid|userid|unionid|groupid|wikispaceid", Required: true},
{Name: "member-kind", Desc: "request body type when --member-type=wikispaceid; one of wiki_space_member|wiki_space_viewer|wiki_space_editor"},
{Name: "perm-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types and wiki-space members"},
},
Tips: []string{
"This command removes exactly one collaborator; run separate commands for multiple members.",
"Resource type is auto-inferred from URL paths; pass --type when --token is a bare token.",
"When --member-type=wikispaceid, pass --member-kind wiki_space_member, wiki_space_viewer, or wiki_space_editor.",
"For ordinary wiki collaborators, --perm-type defaults to container; use single_page to remove only the current-page permission.",
"Department collaborator removal (--member-type=opendepartmentid) requires --as user; bot identity is not supported.",
"A successful response confirms that the removal request completed; it does not prove the permission previously existed.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveMemberRemoveSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveMemberRemoveSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveMemberRemoveDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveMemberRemoveSpec(runtime)
if err != nil {
return err
}
return executeDriveMemberRemove(runtime, spec)
},
}
type driveMemberRemoveSpec struct {
Token string
ResourceType string
MemberID string
MemberType string
MemberKind string
PermType string
}
func (spec driveMemberRemoveSpec) APIQueryParams() map[string]interface{} {
return map[string]interface{}{
"type": spec.ResourceType,
"member_type": spec.MemberType,
}
}
func (spec driveMemberRemoveSpec) Body() map[string]interface{} {
body := make(map[string]interface{}, 2)
if memberKind := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); memberKind != "" {
body["type"] = memberKind
}
if spec.PermType != "" {
body["perm_type"] = spec.PermType
}
return body
}
func readDriveMemberRemoveSpec(runtime *common.RuntimeContext) (driveMemberRemoveSpec, error) {
token, resourceType, err := resolveDriveMemberAddTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveMemberRemoveSpec{}, err
}
if strings.Contains(token, "/") {
return driveMemberRemoveSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--token must resolve to a single resource token and cannot contain '/'",
).WithParam("--token")
}
memberID := strings.TrimSpace(runtime.Str("member-id"))
if memberID == "" {
return driveMemberRemoveSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--member-id is required and cannot be blank",
).WithParam("--member-id")
}
if strings.Contains(memberID, "/") {
return driveMemberRemoveSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--member-id must be a single collaborator ID and cannot contain '/'",
).WithParam("--member-id")
}
if strings.Contains(memberID, ",") {
return driveMemberRemoveSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--member-id accepts exactly one collaborator ID; run one +member-remove command per member",
).WithParam("--member-id")
}
memberType, err := resolveDriveMemberRemoveMemberType(memberID, runtime.Str("member-type"))
if err != nil {
return driveMemberRemoveSpec{}, err
}
if memberType == "wikispaceid" && resourceType != "wiki" {
return driveMemberRemoveSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--member-type=wikispaceid only applies when resource type is wiki; got %q",
resourceType,
).WithParam("--member-type")
}
memberKind, err := resolveDriveMemberAddMemberKind(memberType, runtime.Str("member-kind"))
if err != nil {
return driveMemberRemoveSpec{}, err
}
permType, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm-type"), driveMemberAddPermTypes, "--perm-type")
if err != nil {
return driveMemberRemoveSpec{}, err
}
if resourceType == "wiki" && memberType == "wikispaceid" {
if runtime.Changed("perm-type") {
return driveMemberRemoveSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--perm-type is not supported when --member-type=wikispaceid; use --member-kind wiki_space_member|wiki_space_viewer|wiki_space_editor",
).WithParam("--perm-type")
}
permType = ""
} else if resourceType == "wiki" && permType == "" {
permType = driveMemberAddDefaultPermType(resourceType)
} else if resourceType != "wiki" && runtime.Changed("perm-type") {
return driveMemberRemoveSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--perm-type only applies when resource type is wiki; got %q",
resourceType,
).WithParam("--perm-type")
} else if resourceType != "wiki" {
permType = ""
}
spec := driveMemberRemoveSpec{
Token: token,
ResourceType: resourceType,
MemberID: memberID,
MemberType: memberType,
MemberKind: memberKind,
PermType: permType,
}
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
return driveMemberRemoveSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--member-type=opendepartmentid requires --as user; bot identity does not support removing department collaborators",
).WithParam("--member-type")
}
return spec, nil
}
func resolveDriveMemberRemoveMemberType(memberID, explicit string) (string, error) {
memberType, err := normalizeDriveMemberAddEnumValue(explicit, driveMemberRemoveIDTypes, "--member-type")
if err != nil {
return "", err
}
if memberType == "" {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--member-type is required; accepted values: %s",
strings.Join(driveMemberRemoveIDTypes, ", "),
).WithParam("--member-type")
}
// User IDs are tenant-defined and may resemble another supported ID format.
if memberType != "userid" {
if expected := inferMemberTypeFromID(memberID); expected != "" && expected != memberType {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"member-id %q prefix implies --member-type %s, but --member-type %s was provided; fix the ID or use the matching member type",
memberID,
expected,
memberType,
).WithParam("--member-id")
}
}
return memberType, nil
}
func buildDriveMemberRemoveDryRun(spec driveMemberRemoveSpec) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Remove Drive collaborator/member permission").
DELETE(driveMemberRemovePath(spec)).
Params(spec.APIQueryParams()).
Body(spec.Body())
}
func driveMemberRemovePath(spec driveMemberRemoveSpec) string {
return fmt.Sprintf(
"/open-apis/drive/v1/permissions/%s/members/%s",
validate.EncodePathSegment(spec.Token),
validate.EncodePathSegment(spec.MemberID),
)
}
func executeDriveMemberRemove(runtime *common.RuntimeContext, spec driveMemberRemoveSpec) error {
fmt.Fprintf(
runtime.IO().ErrOut,
"Removing Drive member %s (type=%s) from %s %s...\n",
common.MaskToken(spec.MemberID),
spec.MemberType,
spec.ResourceType,
common.MaskToken(spec.Token),
)
if _, err := runtime.CallAPITyped(
"DELETE",
driveMemberRemovePath(spec),
spec.APIQueryParams(),
spec.Body(),
); err != nil {
return err
}
out := driveMemberRemoveOutput(spec)
fmt.Fprintf(runtime.IO().ErrOut, "Removed Drive member %s\n", common.MaskToken(spec.MemberID))
runtime.Out(out, nil)
return nil
}
func driveMemberRemoveOutput(spec driveMemberRemoveSpec) map[string]interface{} {
out := map[string]interface{}{
"removed": true,
"resource_token": spec.Token,
"resource_type": spec.ResourceType,
"member_id": spec.MemberID,
"member_type": spec.MemberType,
}
if memberKind := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); memberKind != "" {
out["member_kind"] = memberKind
}
if spec.PermType != "" {
out["perm_type"] = spec.PermType
}
return out
}

View File

@@ -0,0 +1,404 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDriveMemberRemoveMetadata(t *testing.T) {
t.Parallel()
if DriveMemberRemove.Service != "drive" || DriveMemberRemove.Command != "+member-remove" {
t.Fatalf("shortcut identity = %s %s", DriveMemberRemove.Service, DriveMemberRemove.Command)
}
if DriveMemberRemove.Risk != "high-risk-write" {
t.Fatalf("risk = %q, want high-risk-write", DriveMemberRemove.Risk)
}
if !reflect.DeepEqual(DriveMemberRemove.Scopes, []string{"docs:permission.member:delete"}) {
t.Fatalf("scopes = %#v", DriveMemberRemove.Scopes)
}
if !reflect.DeepEqual(DriveMemberRemove.AuthTypes, []string{"user", "bot"}) {
t.Fatalf("auth types = %#v", DriveMemberRemove.AuthTypes)
}
wantIDTypes := []string{
"email", "openid", "openchat", "opendepartmentid",
"userid", "unionid", "groupid", "wikispaceid",
}
if !reflect.DeepEqual(driveMemberRemoveIDTypes, wantIDTypes) {
t.Fatalf("member types = %#v, want %#v", driveMemberRemoveIDTypes, wantIDTypes)
}
}
func TestDriveMemberRemoveSpecRequestShape(t *testing.T) {
t.Parallel()
spec := driveMemberRemoveSpec{
Token: "wikcnTok",
ResourceType: "wiki",
MemberID: "ou_member",
MemberType: "openid",
PermType: "single_page",
}
wantParams := map[string]interface{}{"type": "wiki", "member_type": "openid"}
if got := spec.APIQueryParams(); !reflect.DeepEqual(got, wantParams) {
t.Fatalf("params = %#v, want %#v", got, wantParams)
}
wantBody := map[string]interface{}{"type": "user", "perm_type": "single_page"}
if got := spec.Body(); !reflect.DeepEqual(got, wantBody) {
t.Fatalf("body = %#v, want %#v", got, wantBody)
}
wikiSpaceSpec := driveMemberRemoveSpec{
Token: "wikcnTok",
ResourceType: "wiki",
MemberID: "space_member",
MemberType: "wikispaceid",
MemberKind: "wiki_space_viewer",
}
if got := wikiSpaceSpec.Body(); !reflect.DeepEqual(got, map[string]interface{}{"type": "wiki_space_viewer"}) {
t.Fatalf("wiki-space body = %#v", got)
}
}
func TestDriveMemberRemoveOutputConditionalFields(t *testing.T) {
t.Parallel()
wikiSpace := driveMemberRemoveOutput(driveMemberRemoveSpec{
Token: "wikTok",
ResourceType: "wiki",
MemberID: "space_member",
MemberType: "wikispaceid",
MemberKind: "wiki_space_editor",
})
if wikiSpace["removed"] != true || wikiSpace["member_kind"] != "wiki_space_editor" {
t.Fatalf("wiki-space output = %#v", wikiSpace)
}
if _, ok := wikiSpace["perm_type"]; ok {
t.Fatalf("wiki-space output must omit perm_type: %#v", wikiSpace)
}
userID := driveMemberRemoveOutput(driveMemberRemoveSpec{
Token: "doxTok",
ResourceType: "docx",
MemberID: "custom_user",
MemberType: "userid",
})
if userID["member_kind"] != "user" {
t.Fatalf("userid output = %#v, want member_kind user", userID)
}
}
func TestDriveMemberRemoveDryRunInfersURLAndDefaultsWikiPermType(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveMemberRemove, []string{
"+member-remove",
"--token", "https://example.feishu.cn/wiki/wikcnTok?from=share",
"--member-id", "ou_member",
"--member-type", "openid",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(got.Data.API) != 1 {
t.Fatalf("api count = %d, want 1", len(got.Data.API))
}
call := got.Data.API[0]
if call.Method != "DELETE" || call.URL != "/open-apis/drive/v1/permissions/wikcnTok/members/ou_member" {
t.Fatalf("call = %#v", call)
}
if call.Params["type"] != "wiki" || call.Params["member_type"] != "openid" {
t.Fatalf("params = %#v", call.Params)
}
if call.Body["type"] != "user" || call.Body["perm_type"] != "container" {
t.Fatalf("body = %#v", call.Body)
}
}
func TestDriveMemberRemoveValidation(t *testing.T) {
tests := []struct {
name string
args []string
defaultAs string
wantParam string
wantText string
}{
{
name: "rejects slash in normalized token",
args: []string{"--token", "token/with/slash", "--type", "docx", "--member-id", "ou_user", "--member-type", "openid"},
wantParam: "--token",
wantText: "cannot contain '/'",
},
{
name: "rejects slash in member ID",
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "userid/with/slash", "--member-type", "userid"},
wantParam: "--member-id",
wantText: "cannot contain '/'",
},
{
name: "rejects multiple members",
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "ou_a,ou_b", "--member-type", "openid"},
wantParam: "--member-id",
wantText: "exactly one collaborator ID",
},
{
name: "rejects member prefix mismatch",
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "oc_chat", "--member-type", "openid"},
wantParam: "--member-id",
wantText: "implies --member-type openchat",
},
{
name: "rejects app ID",
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "cli_app", "--member-type", "appid"},
wantParam: "--member-type",
wantText: `invalid value "appid"`,
},
{
name: "rejects wiki-space ID outside wiki",
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "space_x", "--member-type", "wikispaceid", "--member-kind", "wiki_space_member"},
wantParam: "--member-type",
wantText: "only applies when resource type is wiki",
},
{
name: "requires member kind for wiki space ID",
args: []string{"--token", "wikTok", "--type", "wiki", "--member-id", "space_x", "--member-type", "wikispaceid"},
wantParam: "--member-kind",
wantText: "--member-kind is required",
},
{
name: "rejects member kind for ordinary member",
args: []string{"--token", "wikTok", "--type", "wiki", "--member-id", "ou_x", "--member-type", "openid", "--member-kind", "wiki_space_member"},
wantParam: "--member-kind",
wantText: "only applies",
},
{
name: "rejects perm type outside wiki",
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "ou_x", "--member-type", "openid", "--perm-type", "single_page"},
wantParam: "--perm-type",
wantText: "only applies when resource type is wiki",
},
{
name: "rejects perm type for wiki space member",
args: []string{"--token", "wikTok", "--type", "wiki", "--member-id", "space_x", "--member-type", "wikispaceid", "--member-kind", "wiki_space_member", "--perm-type", "container"},
wantParam: "--perm-type",
wantText: "not supported when --member-type=wikispaceid",
},
{
name: "rejects department with bot",
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "od_dept", "--member-type", "opendepartmentid"},
defaultAs: "bot",
wantParam: "--member-type",
wantText: "requires --as user",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
args := append([]string{"+member-remove"}, tt.args...)
args = append(args, "--dry-run", "--as", tt.defaultAs)
if tt.defaultAs == "" {
args[len(args)-1] = "user"
}
err := mountAndRunDrive(t, DriveMemberRemove, args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("error = %v, want text %q", err, tt.wantText)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed validation error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("problem = %#v", problem)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != tt.wantParam {
t.Fatalf("validation error = %#v, want param %q", validationErr, tt.wantParam)
}
})
}
}
func TestDriveMemberRemoveAcceptsUserID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveMemberRemove, []string{
"+member-remove",
"--token", "doxTok",
"--type", "docx",
"--member-id", "ou_tenant_defined_user",
"--member-type", "userid",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
Data struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(got.Data.API) != 1 {
t.Fatalf("api count = %d, want 1", len(got.Data.API))
}
if got.Data.API[0].Params["member_type"] != "userid" {
t.Fatalf("params = %#v", got.Data.API[0].Params)
}
if got.Data.API[0].Body["type"] != "user" {
t.Fatalf("body = %#v", got.Data.API[0].Body)
}
}
func TestDriveMemberRemoveExecuteSuccess(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery string
stub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/permissions/wikcnSecretToken/members/ou_secret_member",
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveMemberRemove, []string{
"+member-remove",
"--token", "wikcnSecretToken",
"--type", "wiki",
"--member-id", "ou_secret_member",
"--member-type", "openid",
"--perm-type", "single_page",
"--as", "user",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(capturedQuery, "type=wiki") || !strings.Contains(capturedQuery, "member_type=openid") {
t.Fatalf("query = %q", capturedQuery)
}
var capturedBody map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &capturedBody); err != nil {
t.Fatalf("decode body: %v", err)
}
wantBody := map[string]interface{}{"type": "user", "perm_type": "single_page"}
if !reflect.DeepEqual(capturedBody, wantBody) {
t.Fatalf("body = %#v, want %#v", capturedBody, wantBody)
}
data := decodeDriveEnvelope(t, stdout)
if data["removed"] != true || data["resource_token"] != "wikcnSecretToken" || data["resource_type"] != "wiki" ||
data["member_id"] != "ou_secret_member" || data["member_type"] != "openid" ||
data["member_kind"] != "user" || data["perm_type"] != "single_page" {
t.Fatalf("output = %#v", data)
}
if strings.Contains(stderr.String(), "wikcnSecretToken") || strings.Contains(stderr.String(), "ou_secret_member") {
t.Fatalf("stderr exposes unmasked identifiers: %q", stderr.String())
}
if !strings.Contains(stderr.String(), common.MaskToken("wikcnSecretToken")) || !strings.Contains(stderr.String(), common.MaskToken("ou_secret_member")) {
t.Fatalf("stderr missing masked identifiers: %q", stderr.String())
}
}
func TestDriveMemberRemoveTypedPermissionErrorPassesThrough(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/permissions/doxTok/members/ou_member",
Body: map[string]interface{}{
"code": 1063002,
"msg": "Permission denied",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveMemberRemove, []string{
"+member-remove",
"--token", "doxTok",
"--type", "docx",
"--member-id", "ou_member",
"--member-type", "openid",
"--as", "user",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected API error")
}
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if permissionErr.Code != 1063002 {
t.Fatalf("code = %d, want 1063002", permissionErr.Code)
}
}
func TestDriveMemberRemoveRequiresConfirmation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveMemberRemove, []string{
"+member-remove",
"--token", "doxTok",
"--type", "docx",
"--member-id", "ou_member",
"--member-type", "openid",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation error, got %v", err)
}
}

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ metadata:
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户明确要**移除单个云文档协作者权限**时,使用 `lark-cli drive +member-remove`;先阅读 [`references/lark-drive-member-remove.md`](references/lark-drive-member-remove.md)。这是高风险写操作,真实执行必须确认准确的资源、成员 ID/type 和 wiki 权限范围,并显式传 `--yes`
- 用户要为指定飞书文档**设置 / 修改密级标签secure label**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
@@ -160,6 +161,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`。 |
| [`+member-remove`](references/lark-drive-member-remove.md) | 移除一个 Drive 文档、文件、文件夹或 wiki 节点协作者;封装 Drive permission member delete真实写入需要 `--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,59 @@
# drive +member-remove移除协作者权限
> 这是高风险写操作。真实执行会移除权限,需要核对资源和成员后显式加 `--yes`。
## 命令
```bash
lark-cli drive +member-remove \
--token "<bare_token_or_url>" \
--type docx \
--member-id "ou_xxx" \
--member-type openid \
--yes
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | 裸 token 或完整 URL。URL 可从路径推断类型;裸 token 必须同时传 `--type`。 |
| `--type` | 条件必填 | 资源类型:`docx` / `doc` / `sheet` / `bitable` / `file` / `folder` / `wiki` / `mindnote` / `slides` / `minutes`。完整 URL 可省略。 |
| `--member-id` | 是 | 要移除的单个协作者 ID。逗号分隔的多成员输入会被拒绝批量场景应逐个调用。 |
| `--member-type` | 是 | ID 类型:`email` / `openid` / `openchat` / `opendepartmentid` / `userid` / `unionid` / `groupid` / `wikispaceid`。 |
| `--member-kind` | 条件必填 | 仅 `--member-type=wikispaceid` 使用:未启用知识库成员分组时传 `wiki_space_member`,启用后根据权限传 `wiki_space_viewer``wiki_space_editor`。 |
| `--perm-type` | 否 | 仅 wiki 协作者使用:`container`(默认,当前页面及子页面)或 `single_page`(仅当前页面)。 |
| `--dry-run` | 否 | 只预览 DELETE URL、query 和 body不调用接口。 |
| `--yes` | 真实执行时是 | 确认高风险权限移除操作。 |
## 输出
以移除 `openid` 类型的用户协作者为例,成功后返回:
```json
{
"ok": true,
"identity": "user",
"data": {
"removed": true,
"resource_token": "doxcnxxx",
"resource_type": "docx",
"member_id": "ou_xxx",
"member_type": "openid",
"member_kind": "user"
}
}
```
Wiki 普通协作者还会返回 `perm_type``wikispaceid` 返回所传的 `member_kind`
`removed: true` 表示删除请求成功完成,不保证该权限此前一定存在。
## 行为说明
- **身份支持**:支持 `--as user``--as bot`
- **部门协作者**`--member-type=opendepartmentid` 只能配合 `--as user`bot 身份会在客户端提前拒绝。
- **安全编码**:资源 token 和 member ID 都作为独立 path segment 编码。
- **Wiki 范围**:普通 wiki 协作者默认删除 `container` 权限;只删除当前页面权限时显式传 `single_page`
- **Wiki 空间成员**`--member-type=wikispaceid` 仅支持 `--type=wiki`;必须用 `--member-kind` 指明成员角色,并且不能同时传 `--perm-type`
- **错误处理**OpenAPI 返回的 typed error 原样透传,可根据错误信封中的 subtype、code、hint 和权限信息处理。

View File

@@ -0,0 +1,236 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestDrive_MemberRemoveDryRun(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
wantResourceType string
wantMemberType string
wantMemberKind string
wantPermType string
}{
{
name: "docx URL infers resource type",
args: []string{
"drive", "+member-remove",
"--token", "https://example.feishu.cn/docx/doxcnRemove001?from=share",
"--member-id", "ou_remove_user",
"--member-type", "openid",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnRemove001/members/ou_remove_user",
wantResourceType: "docx",
wantMemberType: "openid",
wantMemberKind: "user",
},
{
name: "user ID is accepted",
args: []string{
"drive", "+member-remove",
"--token", "doxcnRemoveUserID",
"--type", "docx",
"--member-id", "tenant_defined_user",
"--member-type", "userid",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnRemoveUserID/members/tenant_defined_user",
wantResourceType: "docx",
wantMemberType: "userid",
wantMemberKind: "user",
},
{
name: "ordinary wiki member defaults container scope",
args: []string{
"drive", "+member-remove",
"--token", "wikcnE2E002",
"--type", "wiki",
"--member-id", "oc_remove_chat",
"--member-type", "openchat",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E002/members/oc_remove_chat",
wantResourceType: "wiki",
wantMemberType: "openchat",
wantMemberKind: "chat",
wantPermType: "container",
},
{
name: "wiki single-page scope",
args: []string{
"drive", "+member-remove",
"--token", "wikcnE2E003",
"--type", "wiki",
"--member-id", "ou_remove_user",
"--member-type", "openid",
"--perm-type", "single_page",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E003/members/ou_remove_user",
wantResourceType: "wiki",
wantMemberType: "openid",
wantMemberKind: "user",
wantPermType: "single_page",
},
{
name: "wiki-space member uses explicit kind without perm type",
args: []string{
"drive", "+member-remove",
"--token", "wikcnE2E004",
"--type", "wiki",
"--member-id", "space_remove_member",
"--member-type", "wikispaceid",
"--member-kind", "wiki_space_editor",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E004/members/space_remove_member",
wantResourceType: "wiki",
wantMemberType: "wikispaceid",
wantMemberKind: "wiki_space_editor",
},
}
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
require.Equal(t, "DELETE", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, tt.wantURL, clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, tt.wantResourceType, clie2e.DryRunGet(out, "api.0.params.type").String(), "stdout:\n%s", out)
require.Equal(t, tt.wantMemberType, clie2e.DryRunGet(out, "api.0.params.member_type").String(), "stdout:\n%s", out)
require.Equal(t, tt.wantMemberKind, clie2e.DryRunGet(out, "api.0.body.type").String(), "stdout:\n%s", out)
permType := clie2e.DryRunGet(out, "api.0.body.perm_type")
if tt.wantPermType == "" {
require.False(t, permType.Exists(), "perm_type should be omitted\nstdout:\n%s", out)
} else {
require.Equal(t, tt.wantPermType, permType.String(), "stdout:\n%s", out)
}
})
}
}
func TestDrive_MemberRemoveDryRunRejectsInvalidInputs(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
defaultAs string
wantErr string
}{
{
name: "slash in normalized token is rejected",
args: []string{"drive", "+member-remove", "--token", "token/with/slash", "--type", "docx", "--member-id", "ou_user", "--member-type", "openid", "--dry-run"},
wantErr: "--token must resolve to a single resource token and cannot contain '/'",
},
{
name: "slash in member ID is rejected",
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "userid/with/slash", "--member-type", "userid", "--dry-run"},
wantErr: "--member-id must be a single collaborator ID and cannot contain '/'",
},
{
name: "bare token requires type",
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--member-id", "ou_user", "--member-type", "openid", "--dry-run"},
wantErr: "--type is required when --token is a bare token",
},
{
name: "multiple members are rejected",
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "ou_a,ou_b", "--member-type", "openid", "--dry-run"},
wantErr: "exactly one collaborator ID",
},
{
name: "wiki-space ID requires member kind",
args: []string{"drive", "+member-remove", "--token", "wikcnRemove", "--type", "wiki", "--member-id", "space_member", "--member-type", "wikispaceid", "--dry-run"},
wantErr: "--member-kind is required",
},
{
name: "app ID is rejected",
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "cli_app", "--member-type", "appid", "--dry-run"},
wantErr: "allowed: email, openid, openchat, opendepartmentid, userid, unionid, groupid, wikispaceid",
},
{
name: "wiki-space ID is rejected outside wiki",
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "space_member", "--member-type", "wikispaceid", "--member-kind", "wiki_space_member", "--dry-run"},
wantErr: "only applies when resource type is wiki",
},
{
name: "non-wiki rejects perm type",
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "ou_user", "--member-type", "openid", "--perm-type", "single_page", "--dry-run"},
wantErr: "only applies when resource type is wiki",
},
{
name: "bot rejects department member",
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "od_department", "--member-type", "opendepartmentid", "--dry-run"},
defaultAs: "bot",
wantErr: "requires --as user",
},
}
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)
defaultAs := tt.defaultAs
if defaultAs == "" {
defaultAs = "user"
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: defaultAs})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stdout+result.Stderr, tt.wantErr, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
})
}
}
func TestDrive_MemberRemoveRequiresConfirmation(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", "+member-remove",
"--token", "doxcnRemove",
"--type", "docx",
"--member-id", "ou_user",
"--member-type", "openid",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 10)
require.Contains(t, result.Stderr, "confirmation_required", "stderr:\n%s", result.Stderr)
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_MemberRemoveWorkflowAsUser(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := CreateDriveFolder(t, t, ctx, "lark-cli-e2e-member-remove-"+suffix, "user", "")
docToken := createMemberRemoveWorkflowDoc(t, ctx, folderToken, suffix)
botOpenID := getMemberRemoveWorkflowBotOpenID(t, ctx)
addResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+member-add",
"--token", docToken,
"--type", "docx",
"--member-id", botOpenID,
"--member-type", "openid",
"--perm", "view",
"--yes",
},
DefaultAs: "user",
})
require.NoError(t, err)
addResult.AssertExitCode(t, 0)
addResult.AssertStdoutStatus(t, true)
require.Equal(t, botOpenID, gjson.Get(addResult.Stdout, "data.member_id").String(), "stdout:\n%s", addResult.Stdout)
removeResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+member-remove",
"--token", docToken,
"--type", "docx",
"--member-id", botOpenID,
"--member-type", "openid",
"--yes",
},
DefaultAs: "user",
})
require.NoError(t, err)
removeResult.AssertExitCode(t, 0)
removeResult.AssertStdoutStatus(t, true)
require.True(t, gjson.Get(removeResult.Stdout, "data.removed").Bool(), "stdout:\n%s", removeResult.Stdout)
require.Equal(t, docToken, gjson.Get(removeResult.Stdout, "data.resource_token").String(), "stdout:\n%s", removeResult.Stdout)
require.Equal(t, "docx", gjson.Get(removeResult.Stdout, "data.resource_type").String(), "stdout:\n%s", removeResult.Stdout)
require.Equal(t, botOpenID, gjson.Get(removeResult.Stdout, "data.member_id").String(), "stdout:\n%s", removeResult.Stdout)
require.Equal(t, "openid", gjson.Get(removeResult.Stdout, "data.member_type").String(), "stdout:\n%s", removeResult.Stdout)
}
func createMemberRemoveWorkflowDoc(t *testing.T, ctx context.Context, folderToken, suffix string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--parent-token", folderToken,
"--doc-format", "markdown",
"--content", "# member-remove-" + suffix + "\n\nTemporary permission workflow fixture.",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
docToken := gjson.Get(result.Stdout, "data.document.document_id").String()
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
t.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := DeleteDriveResourceAndVerify(cleanupCtx, docToken, "docx", "user")
clie2e.ReportCleanupFailure(t, "delete member-remove workflow doc "+docToken, deleteResult, deleteErr)
})
return docToken
}
func getMemberRemoveWorkflowBotOpenID(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"auth", "status", "--json", "--verify"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.True(t, gjson.Get(result.Stdout, "identities.bot.verified").Bool(), "stdout:\n%s", result.Stdout)
openID := gjson.Get(result.Stdout, "identities.bot.openId").String()
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
return openID
}