From fd0ae2c46c8afaa33c02df9d67e75c63e988f4c4 Mon Sep 17 00:00:00 2001 From: pvyazankin Date: Mon, 24 Aug 2026 16:35:26 +0200 Subject: [PATCH] MILAB-6761: add `pl-cli admin delete-user` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-provider auth can hand one person two accounts. The spare showed up in the sharing user picker and its projects kept taking part in deduplication, and no shipped tool removed one — the migration guide said as much. `admin delete-user ` now does, over the new AuthAPI.DeleteUser RPC. When the account still owns projects it requires an explicit decision rather than picking a default, because both defaults are wrong to assume — one silently destroys data, the other silently hands it to someone: --move-projects-to re-attach every project to another user's root, then delete the account --delete-projects delete the projects with the account The move is a move, not a copy: the same project resources are re-homed, so nothing is duplicated and nothing needs re-verifying. This works across roots because the backend allows a reference between differently coloured resources when the caller holds write access to both. A name the target already uses is suffixed rather than overwritten, and the target's project list is created if they never had one. The whole batch is one transaction. Both forms prompt with the affected project list first; --force skips it. pl-client gains PlClient.deleteUser (gRPC-only, like listUsers), and pl-middle-layer exports ProjectsResourceType, which a caller writing into another user's root needs. --- .changeset/pl-cli-delete-user.md | 18 ++ .../proto/plapi/plapiproto/api.proto | 36 +++ lib/node/pl-client/src/core/client.ts | 9 + lib/node/pl-client/src/core/ll_client.ts | 14 ++ lib/node/pl-client/src/core/user_resources.ts | 35 +++ .../pl/plapi/plapiproto/api.client.ts | 37 +++- .../milaboratory/pl/plapi/plapiproto/api.ts | 205 ++++++++++++++++++ .../pl-middle-layer/src/middle_layer/index.ts | 2 +- tools/pl-cli/src/cli.ts | 2 + tools/pl-cli/src/cmd/admin/delete-user.ts | 172 +++++++++++++++ tools/pl-cli/src/project_ops.ts | 168 +++++++++++++- 11 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 .changeset/pl-cli-delete-user.md create mode 100644 tools/pl-cli/src/cmd/admin/delete-user.ts diff --git a/.changeset/pl-cli-delete-user.md b/.changeset/pl-cli-delete-user.md new file mode 100644 index 0000000000..e31744c4fa --- /dev/null +++ b/.changeset/pl-cli-delete-user.md @@ -0,0 +1,18 @@ +--- +'@platforma-sdk/pl-cli': minor +'@milaboratories/pl-client': minor +'@milaboratories/pl-middle-layer': patch +--- + +pl-cli: add `admin delete-user`, so a duplicate user account can be removed. + +Multi-provider auth can hand one person two accounts — a misconfigured provider, or an identity that could not be matched by email across a cutover. The spare account was not inert: it appeared in the sharing user picker, and the projects in its root kept taking part in deduplication. Nothing removed one. + +`admin delete-user ` now does, backed by the new `AuthAPI.DeleteUser` RPC. When the account still owns projects it requires an explicit decision rather than picking a default, since both defaults are wrong to assume: + +- `--move-projects-to ` re-attaches every project to another user's root and then deletes the account. It is a move, not a copy: the same project resources are re-homed, so nothing is duplicated and nothing needs re-verifying. A name the target already uses is suffixed rather than overwritten, and the target's project list is created if they never had one. +- `--delete-projects` deletes the projects along with the account. + +Both prompt with the affected project list first; `--force` skips that for scripted runs. Deleting an account removes its record, login-index entries, grants and root resource, and frees the login — the person's next sign-in lands on a clean account instead of reviving the deleted one. Requires admin/controller credentials, and refuses to target the account those credentials authenticate as. + +`pl-client` gains `PlClient.deleteUser(login)` (gRPC-only, like `listUsers`). `pl-middle-layer` now exports `ProjectsResourceType`, which a caller writing into another user's root needs. diff --git a/lib/node/pl-client/proto/plapi/plapiproto/api.proto b/lib/node/pl-client/proto/plapi/plapiproto/api.proto index a1cdf06550..ea794d479b 100644 --- a/lib/node/pl-client/proto/plapi/plapiproto/api.proto +++ b/lib/node/pl-client/proto/plapi/plapiproto/api.proto @@ -261,6 +261,21 @@ service Platform { rpc ListUsers(AuthAPI.ListUsers.Request) returns (AuthAPI.ListUsers.Response) {} + // DeleteUser removes a user account from the instance: the user record, every login-index + // entry that resolves to it, every grant it holds, and its root resource with everything + // still attached under it. Admin-only and irreversible - re-attach anything worth keeping + // to another user's root before calling (`pl-cli admin delete-user` does both steps). + // + // Exists because multi-provider auth can mint a duplicate account for one person: the waste + // record shows up in the sharing UI and its projects keep taking part in deduplication, and + // until now nothing could remove it. + rpc DeleteUser(AuthAPI.DeleteUser.Request) returns (AuthAPI.DeleteUser.Response) { + option (google.api.http) = { + post: "/v1/auth/delete-user" + body: "*" + }; + } + // // Other stuff // @@ -1974,6 +1989,27 @@ message AuthAPI { string login = 1; } + message DeleteUser { + message Request { + // login of the user to delete. Resolved the way an admin-supplied grant target is: + // across the configured providers, then the legacy empty-idp bucket, then by a login + // attribute unique across records - so an account left behind by a provider that has + // since been removed is reachable too. + string login = 1; + } + + message Response { + // root resource the user owned; 0 when the user had none. + uint64 user_root_id = 1; + // whether that root resource was deleted. + bool user_root_deleted = 2; + // grants revoked, the user-root self-grant included. + uint32 revoked_grants = 3; + // login-index entries removed. + uint32 removed_login_index_entries = 4; + } + } + message ListUsers { message Request {} diff --git a/lib/node/pl-client/src/core/client.ts b/lib/node/pl-client/src/core/client.ts index 1d412c74f3..cc364f7d8a 100644 --- a/lib/node/pl-client/src/core/client.ts +++ b/lib/node/pl-client/src/core/client.ts @@ -33,6 +33,7 @@ import type { WireConnection } from "./wire"; import { advisoryLock } from "./advisory_locks"; import { plAddressToConfig } from "./config"; import { UserResources } from "./user_resources"; +import type { UserDeletionReport } from "./user_resources"; import type { BackendCapability } from "./capabilities"; export type TxOps = PlCallOps & { @@ -207,6 +208,14 @@ export class PlClient { return await this.ll.listUsers(); } + /** + * Deletes a user account and its root resource — with the root, everything still attached + * under it. Destructive and irreversible; see {@link UserResources.deleteUser}. + */ + public async deleteUser(login: string): Promise { + return await this.userResources.deleteUser(login); + } + /** * Returns the user root SignedResourceId via ListUserResources. * @param opts.login - target user login; omit for the authenticated user. diff --git a/lib/node/pl-client/src/core/ll_client.ts b/lib/node/pl-client/src/core/ll_client.ts index bbd806237c..807e44f50c 100644 --- a/lib/node/pl-client/src/core/ll_client.ts +++ b/lib/node/pl-client/src/core/ll_client.ts @@ -1077,6 +1077,20 @@ export class LLPlClient implements WireClientProviderFactory { return (await cl.listUsers({})).response.users; } + /** Deletes a user account: the record, its login-index entries, its grants, and its root + * resource with everything still attached under it. Admin/controller credentials only, and + * irreversible — re-attach anything worth keeping to another user's root first. + * gRPC-only (unary, no REST binding), like {@link listUsers}. */ + public async deleteUser(login: string): Promise { + const cl = this.clientProvider.get(); + + if (!(cl instanceof GrpcPlApiClient)) { + throw new Error("DeleteUser requires gRPC wire protocol; REST is not supported"); + } + + return (await cl.deleteUser({ login })).response; + } + public async txSync(txId: bigint): Promise { const cl = this.clientProvider.get(); if (cl instanceof GrpcPlApiClient) { diff --git a/lib/node/pl-client/src/core/user_resources.ts b/lib/node/pl-client/src/core/user_resources.ts index bcbe12908e..bf566c892c 100644 --- a/lib/node/pl-client/src/core/user_resources.ts +++ b/lib/node/pl-client/src/core/user_resources.ts @@ -36,6 +36,19 @@ export interface UserEntry { readonly login: string; } +/** What {@link UserResources.deleteUser} removed, so a caller can report it. */ +export interface UserDeletionReport { + /** Raw id of the root resource the user owned, or undefined when they had none. Unsigned — + * the resource is gone, so this is for the audit line, not for further calls. */ + readonly userRootId: bigint | undefined; + /** Whether that root resource was deleted. */ + readonly userRootDeleted: boolean; + /** Grants revoked, the user-root self-grant included. */ + readonly revokedGrants: number; + /** Login-index entries removed — one per (provider, match key) the login was indexed under. */ + readonly removedLoginIndexEntries: number; +} + /** Information about a single data library (LS storage). */ export interface StorageInfo { /** Machine-stable identifier, e.g. "library". Used for filtering and map keys. */ @@ -257,6 +270,28 @@ export class UserResources { return users.map((user) => ({ login: user.login })); } + /** + * Deletes a user account: the record, every login-index entry that resolves to it, every grant + * it holds, and its root resource — and with the root, everything still attached under it. + * + * Exists for the duplicate a misconfigured provider can mint for one person: the spare record + * shows up wherever users are listed and its projects keep taking part in deduplication. + * + * Destructive and irreversible. Re-attach anything worth keeping to another user's root before + * calling — `pl-cli admin delete-user` does both steps in order. Requires admin/controller + * credentials; gRPC-only, like {@link listUsers}. + */ + async deleteUser(login: string): Promise { + const resp = await this.ll.deleteUser(login); + + return { + userRootId: resp.userRootId === 0n ? undefined : resp.userRootId, + userRootDeleted: resp.userRootDeleted, + revokedGrants: resp.revokedGrants, + removedLoginIndexEntries: resp.removedLoginIndexEntries, + }; + } + private async getUserRootViaRpc(opts: { login?: string; createIfNotExists: true; diff --git a/lib/node/pl-client/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.ts b/lib/node/pl-client/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.ts index a4428fee76..f80970d089 100644 --- a/lib/node/pl-client/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.ts +++ b/lib/node/pl-client/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.client.ts @@ -10,6 +10,8 @@ import type { MaintenanceAPI_Ping_Response } from "./api"; import type { MaintenanceAPI_Ping_Request } from "./api"; import type { MiscAPI_ListResourceTypes_Response } from "./api"; import type { MiscAPI_ListResourceTypes_Request } from "./api"; +import type { AuthAPI_DeleteUser_Response } from "./api"; +import type { AuthAPI_DeleteUser_Request } from "./api"; import type { AuthAPI_ListUsers_Response } from "./api"; import type { AuthAPI_ListUsers_Request } from "./api"; import type { AuthAPI_ListUserResources_Response } from "./api"; @@ -290,6 +292,19 @@ export interface IPlatformClient { * @generated from protobuf rpc: ListUsers */ listUsers(input: AuthAPI_ListUsers_Request, options?: RpcOptions): UnaryCall; + /** + * DeleteUser removes a user account from the instance: the user record, every login-index + * entry that resolves to it, every grant it holds, and its root resource with everything + * still attached under it. Admin-only and irreversible - re-attach anything worth keeping + * to another user's root before calling (`pl-cli admin delete-user` does both steps). + * + * Exists because multi-provider auth can mint a duplicate account for one person: the waste + * record shows up in the sharing UI and its projects keep taking part in deduplication, and + * until now nothing could remove it. + * + * @generated from protobuf rpc: DeleteUser + */ + deleteUser(input: AuthAPI_DeleteUser_Request, options?: RpcOptions): UnaryCall; /** * * Other stuff @@ -626,6 +641,22 @@ export class PlatformClient implements IPlatformClient, ServiceInfo { const method = this.methods[34], opt = this._transport.mergeOptions(options); return stackIntercept("unary", this._transport, method, opt, input); } + /** + * DeleteUser removes a user account from the instance: the user record, every login-index + * entry that resolves to it, every grant it holds, and its root resource with everything + * still attached under it. Admin-only and irreversible - re-attach anything worth keeping + * to another user's root before calling (`pl-cli admin delete-user` does both steps). + * + * Exists because multi-provider auth can mint a duplicate account for one person: the waste + * record shows up in the sharing UI and its projects keep taking part in deduplication, and + * until now nothing could remove it. + * + * @generated from protobuf rpc: DeleteUser + */ + deleteUser(input: AuthAPI_DeleteUser_Request, options?: RpcOptions): UnaryCall { + const method = this.methods[35], opt = this._transport.mergeOptions(options); + return stackIntercept("unary", this._transport, method, opt, input); + } /** * * Other stuff @@ -634,7 +665,7 @@ export class PlatformClient implements IPlatformClient, ServiceInfo { * @generated from protobuf rpc: ListResourceTypes */ listResourceTypes(input: MiscAPI_ListResourceTypes_Request, options?: RpcOptions): UnaryCall { - const method = this.methods[35], opt = this._transport.mergeOptions(options); + const method = this.methods[36], opt = this._transport.mergeOptions(options); return stackIntercept("unary", this._transport, method, opt, input); } /** @@ -645,14 +676,14 @@ export class PlatformClient implements IPlatformClient, ServiceInfo { * @generated from protobuf rpc: Ping */ ping(input: MaintenanceAPI_Ping_Request, options?: RpcOptions): UnaryCall { - const method = this.methods[36], opt = this._transport.mergeOptions(options); + const method = this.methods[37], opt = this._transport.mergeOptions(options); return stackIntercept("unary", this._transport, method, opt, input); } /** * @generated from protobuf rpc: License */ license(input: MaintenanceAPI_License_Request, options?: RpcOptions): UnaryCall { - const method = this.methods[37], opt = this._transport.mergeOptions(options); + const method = this.methods[38], opt = this._transport.mergeOptions(options); return stackIntercept("unary", this._transport, method, opt, input); } } diff --git a/lib/node/pl-client/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.ts b/lib/node/pl-client/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.ts index 1f7132c7d1..c1210e2920 100644 --- a/lib/node/pl-client/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.ts +++ b/lib/node/pl-client/src/proto-grpc/github.com/milaboratory/pl/plapi/plapiproto/api.ts @@ -4237,6 +4237,54 @@ export interface AuthAPI_User { */ login: string; } +/** + * @generated from protobuf message MiLaboratories.PL.API.AuthAPI.DeleteUser + */ +export interface AuthAPI_DeleteUser { +} +/** + * @generated from protobuf message MiLaboratories.PL.API.AuthAPI.DeleteUser.Request + */ +export interface AuthAPI_DeleteUser_Request { + /** + * login of the user to delete. Resolved the way an admin-supplied grant target is: + * across the configured providers, then the legacy empty-idp bucket, then by a login + * attribute unique across records - so an account left behind by a provider that has + * since been removed is reachable too. + * + * @generated from protobuf field: string login = 1 + */ + login: string; +} +/** + * @generated from protobuf message MiLaboratories.PL.API.AuthAPI.DeleteUser.Response + */ +export interface AuthAPI_DeleteUser_Response { + /** + * root resource the user owned; 0 when the user had none. + * + * @generated from protobuf field: uint64 user_root_id = 1 + */ + userRootId: bigint; + /** + * whether that root resource was deleted. + * + * @generated from protobuf field: bool user_root_deleted = 2 + */ + userRootDeleted: boolean; + /** + * grants revoked, the user-root self-grant included. + * + * @generated from protobuf field: uint32 revoked_grants = 3 + */ + revokedGrants: number; + /** + * login-index entries removed. + * + * @generated from protobuf field: uint32 removed_login_index_entries = 4 + */ + removedLoginIndexEntries: number; +} /** * @generated from protobuf message MiLaboratories.PL.API.AuthAPI.ListUsers */ @@ -19974,6 +20022,162 @@ class AuthAPI_User$Type extends MessageType { */ export const AuthAPI_User = new AuthAPI_User$Type(); // @generated message type with reflection information, may provide speed optimized methods +class AuthAPI_DeleteUser$Type extends MessageType { + constructor() { + super("MiLaboratories.PL.API.AuthAPI.DeleteUser", []); + } + create(value?: PartialMessage): AuthAPI_DeleteUser { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: AuthAPI_DeleteUser): AuthAPI_DeleteUser { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: AuthAPI_DeleteUser, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message MiLaboratories.PL.API.AuthAPI.DeleteUser + */ +export const AuthAPI_DeleteUser = new AuthAPI_DeleteUser$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class AuthAPI_DeleteUser_Request$Type extends MessageType { + constructor() { + super("MiLaboratories.PL.API.AuthAPI.DeleteUser.Request", [ + { no: 1, name: "login", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): AuthAPI_DeleteUser_Request { + const message = globalThis.Object.create((this.messagePrototype!)); + message.login = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: AuthAPI_DeleteUser_Request): AuthAPI_DeleteUser_Request { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string login */ 1: + message.login = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: AuthAPI_DeleteUser_Request, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string login = 1; */ + if (message.login !== "") + writer.tag(1, WireType.LengthDelimited).string(message.login); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message MiLaboratories.PL.API.AuthAPI.DeleteUser.Request + */ +export const AuthAPI_DeleteUser_Request = new AuthAPI_DeleteUser_Request$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class AuthAPI_DeleteUser_Response$Type extends MessageType { + constructor() { + super("MiLaboratories.PL.API.AuthAPI.DeleteUser.Response", [ + { no: 1, name: "user_root_id", kind: "scalar", T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "user_root_deleted", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 3, name: "revoked_grants", kind: "scalar", T: 13 /*ScalarType.UINT32*/ }, + { no: 4, name: "removed_login_index_entries", kind: "scalar", T: 13 /*ScalarType.UINT32*/ } + ]); + } + create(value?: PartialMessage): AuthAPI_DeleteUser_Response { + const message = globalThis.Object.create((this.messagePrototype!)); + message.userRootId = 0n; + message.userRootDeleted = false; + message.revokedGrants = 0; + message.removedLoginIndexEntries = 0; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: AuthAPI_DeleteUser_Response): AuthAPI_DeleteUser_Response { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* uint64 user_root_id */ 1: + message.userRootId = reader.uint64().toBigInt(); + break; + case /* bool user_root_deleted */ 2: + message.userRootDeleted = reader.bool(); + break; + case /* uint32 revoked_grants */ 3: + message.revokedGrants = reader.uint32(); + break; + case /* uint32 removed_login_index_entries */ 4: + message.removedLoginIndexEntries = reader.uint32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: AuthAPI_DeleteUser_Response, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* uint64 user_root_id = 1; */ + if (message.userRootId !== 0n) + writer.tag(1, WireType.Varint).uint64(message.userRootId); + /* bool user_root_deleted = 2; */ + if (message.userRootDeleted !== false) + writer.tag(2, WireType.Varint).bool(message.userRootDeleted); + /* uint32 revoked_grants = 3; */ + if (message.revokedGrants !== 0) + writer.tag(3, WireType.Varint).uint32(message.revokedGrants); + /* uint32 removed_login_index_entries = 4; */ + if (message.removedLoginIndexEntries !== 0) + writer.tag(4, WireType.Varint).uint32(message.removedLoginIndexEntries); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message MiLaboratories.PL.API.AuthAPI.DeleteUser.Response + */ +export const AuthAPI_DeleteUser_Response = new AuthAPI_DeleteUser_Response$Type(); +// @generated message type with reflection information, may provide speed optimized methods class AuthAPI_ListUsers$Type extends MessageType { constructor() { super("MiLaboratories.PL.API.AuthAPI.ListUsers", []); @@ -20728,6 +20932,7 @@ export const Platform = new ServiceType("MiLaboratories.PL.API.Platform", [ { name: "GetUserRoot", options: { "google.api.http": { post: "/v1/auth/user-root", body: "*" } }, I: AuthAPI_GetUserRoot_Request, O: AuthAPI_GetUserRoot_Response }, { name: "ListUserResources", serverStreaming: true, options: {}, I: AuthAPI_ListUserResources_Request, O: AuthAPI_ListUserResources_Response }, { name: "ListUsers", options: {}, I: AuthAPI_ListUsers_Request, O: AuthAPI_ListUsers_Response }, + { name: "DeleteUser", options: { "google.api.http": { post: "/v1/auth/delete-user", body: "*" } }, I: AuthAPI_DeleteUser_Request, O: AuthAPI_DeleteUser_Response }, { name: "ListResourceTypes", options: { "google.api.http": { get: "/v1/resource-types" } }, I: MiscAPI_ListResourceTypes_Request, O: MiscAPI_ListResourceTypes_Response }, { name: "Ping", options: { "google.api.http": { get: "/v1/ping" } }, I: MaintenanceAPI_Ping_Request, O: MaintenanceAPI_Ping_Response }, { name: "License", options: { "google.api.http": { get: "/v1/license" } }, I: MaintenanceAPI_License_Request, O: MaintenanceAPI_License_Response } diff --git a/lib/node/pl-middle-layer/src/middle_layer/index.ts b/lib/node/pl-middle-layer/src/middle_layer/index.ts index b06e189d56..88f73da03f 100644 --- a/lib/node/pl-middle-layer/src/middle_layer/index.ts +++ b/lib/node/pl-middle-layer/src/middle_layer/index.ts @@ -2,5 +2,5 @@ export { MiddleLayer } from "./middle_layer"; export { Project } from "./project"; export * from "./driver_kit"; export * from "./ops"; -export { ProjectsField } from "./project_list"; +export { ProjectsField, ProjectsResourceType } from "./project_list"; export type { OutgoingShare, PendingShare } from "./sharing_list"; diff --git a/tools/pl-cli/src/cli.ts b/tools/pl-cli/src/cli.ts index 2288705cc4..d3d94fffc0 100644 --- a/tools/pl-cli/src/cli.ts +++ b/tools/pl-cli/src/cli.ts @@ -6,6 +6,7 @@ import projectRenameCommand from "./cmd/project/rename"; import projectDeleteCommand from "./cmd/project/delete"; import adminCopyProjectCommand from "./cmd/admin/copy-project"; import adminUserListCommand from "./cmd/admin/user-list"; +import adminDeleteUserCommand from "./cmd/admin/delete-user"; export function buildProgram(): Command { const program = new Command(); @@ -24,6 +25,7 @@ export function buildProgram(): Command { ); admin.addCommand(adminCopyProjectCommand()); admin.addCommand(adminUserListCommand()); + admin.addCommand(adminDeleteUserCommand()); program.addCommand(admin); return program; diff --git a/tools/pl-cli/src/cmd/admin/delete-user.ts b/tools/pl-cli/src/cmd/admin/delete-user.ts new file mode 100644 index 0000000000..4bb966d407 --- /dev/null +++ b/tools/pl-cli/src/cmd/admin/delete-user.ts @@ -0,0 +1,172 @@ +import { Command, Option } from "commander"; +import { createInterface } from "node:readline"; +import { connectClient } from "../../base_command"; +import { addOptions, GlobalOptions, AdminAuthOptions } from "../../cmd-opts"; +import { + ensureUserProjectList, + listProjectIdentities, + moveProjects, + openUserRoot, +} from "../../project_ops"; +import type { MovedProject, ProjectIdentityWithLabel } from "../../project_ops"; +import { formatTable, outputJson, outputText } from "../../output"; + +export default function adminDeleteUserCommand(): Command { + const cmd = new Command("delete-user").description( + "Delete a user account. Optionally re-homes the user's projects to another user first. " + + "Requires admin/controller credentials.", + ); + + cmd.argument("", "Username of the account to delete"); + addOptions(cmd, GlobalOptions(), AdminAuthOptions()); + cmd.addOption( + new Option( + "--move-projects-to ", + "Re-home the deleted user's projects to this user before deleting the account", + ), + ); + cmd.addOption( + new Option( + "--delete-projects", + "Delete the user's projects along with the account, losing their data", + ), + ); + cmd.option("--force", "Skip confirmation", false); + + cmd.action(async (user: string, flags) => { + // The two modes are alternatives, not a combination: one keeps the projects, the other + // destroys them, and a request for both says nothing about which was meant. + if (flags.moveProjectsTo && flags.deleteProjects) { + throw new Error("--move-projects-to and --delete-projects are mutually exclusive"); + } + if (flags.moveProjectsTo === user) { + throw new Error("--move-projects-to must name a different user than the one being deleted"); + } + // Caught here as well as server-side, where it is authoritative: the operator gets the reason + // instead of a failure from whichever lookup happens to run first. + if (flags.adminUser === user) { + throw new Error( + `Refusing to delete "${user}" — it is the account these credentials authenticate as.`, + ); + } + + const pl = await connectClient(flags); + try { + const source = await openUserRoot(pl, user); + // No project list means the account never opened the app, so it owns nothing — a state to + // delete straight through, not to fail on. + const projects = + source.projectListRid === undefined + ? [] + : await listProjectIdentities(pl, source.projectListRid); + + // Neither mode given while the account still owns projects: refuse rather than pick. Both + // defaults are wrong to assume — one silently destroys data, the other silently hands it to + // someone. An account with no projects has nothing to decide about, so it just proceeds. + if (projects.length > 0 && !flags.moveProjectsTo && !flags.deleteProjects) { + throw new Error( + `User "${user}" owns ${projects.length} project(s). Pass --move-projects-to to ` + + "re-home them, or --delete-projects to delete them with the account.", + ); + } + + const confirmed = await confirmDeletion(user, projects, flags); + if (!confirmed) { + outputText("Aborted."); + return; + } + + let moved: MovedProject[] = []; + if (flags.moveProjectsTo && projects.length > 0) { + const target = await openUserRoot(pl, flags.moveProjectsTo); + // Created if the target has none, so re-homing to a user who has never opened the app + // works rather than failing halfway with the account still present. + const targetList = await ensureUserProjectList(pl, target.userRoot); + moved = await moveProjects(pl, source.projectListRid!, targetList, projects); + } + + // Deleting the account takes its root with it, and with the root every project still + // attached — which is why the move above has to have committed first. + const report = await pl.deleteUser(user); + + if (flags.format === "json") { + outputJson({ + deleted: true, + user, + movedTo: flags.moveProjectsTo ?? null, + movedProjects: moved, + deletedProjects: flags.moveProjectsTo ? [] : projects.map((p) => p.label), + userRootId: report.userRootId === undefined ? null : report.userRootId.toString(), + userRootDeleted: report.userRootDeleted, + revokedGrants: report.revokedGrants, + removedLoginIndexEntries: report.removedLoginIndexEntries, + }); + } else { + outputText(renderResult(user, flags.moveProjectsTo, moved, projects)); + } + } finally { + await pl.close(); + } + }); + + return cmd; +} + +/** Asks the operator to confirm, spelling out which projects are affected and how. */ +async function confirmDeletion( + user: string, + projects: ProjectIdentityWithLabel[], + flags: { moveProjectsTo?: string; force?: boolean }, +): Promise { + if (flags.force) return true; + + const fate = flags.moveProjectsTo + ? `${projects.length} project(s) will move to "${flags.moveProjectsTo}"` + : projects.length > 0 + ? `${projects.length} project(s) will be PERMANENTLY DELETED` + : "the account owns no projects"; + + process.stderr.write(`Delete user "${user}"? ${fate}.\n`); + if (projects.length > 0) { + process.stderr.write(projects.map((p) => ` - ${p.label}`).join("\n") + "\n"); + } + + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = await new Promise((resolve) => { + rl.question("Proceed? [y/N] ", resolve); + }); + return answer.toLowerCase() === "y"; + } finally { + rl.close(); + } +} + +function renderResult( + user: string, + movedTo: string | undefined, + moved: MovedProject[], + projects: ProjectIdentityWithLabel[], +): string { + const lines: string[] = []; + + if (movedTo) { + if (moved.length > 0) { + lines.push(`Moved ${moved.length} project(s) from ${user} to ${movedTo}:`); + lines.push( + formatTable( + ["id", "name", "name in target"], + moved.map((p) => [p.id, p.sourceLabel, p.targetLabel]), + ), + ); + } else { + lines.push(`User ${user} had no projects to move.`); + } + } else if (projects.length > 0) { + lines.push(`Deleted ${projects.length} project(s) belonging to ${user}:`); + lines.push(projects.map((p) => ` - ${p.label}`).join("\n")); + } + + lines.push(`Deleted user "${user}".`); + return lines.join("\n"); +} diff --git a/tools/pl-cli/src/project_ops.ts b/tools/pl-cli/src/project_ops.ts index 87547c7f24..70a53e9efc 100644 --- a/tools/pl-cli/src/project_ops.ts +++ b/tools/pl-cli/src/project_ops.ts @@ -1,5 +1,11 @@ import type { PlClient, SignedResourceId, PlTransaction } from "@milaboratories/pl-client"; -import { field, isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client"; +import { + field, + isNotFoundError, + isNullSignedResourceId, + resourceIdToString, +} from "@milaboratories/pl-client"; +import { randomUUID } from "node:crypto"; import { ProjectMetaKey, ProjectCreatedTimestamp, @@ -7,6 +13,7 @@ import { SchemaVersionKey, ProjectStructureKey, ProjectsField, + ProjectsResourceType, duplicateProject, } from "@milaboratories/pl-middle-layer"; import type { ProjectMeta } from "@milaboratories/pl-middle-layer"; @@ -69,6 +76,42 @@ export async function listProjects( }); } +/** A project's identity plus its label — what a whole-root operation needs per project. */ +export interface ProjectIdentityWithLabel extends ProjectIdentity { + label: string; +} + +/** + * Lists every project in a project list with the field name holding it, which + * {@link listProjects} omits. Whole-root operations (moving a user's projects out before the + * account is deleted) need that field name to detach each project from its old owner. + */ +export async function listProjectIdentities( + pl: PlClient, + projectListRid: SignedResourceId, +): Promise { + return await pl.withReadTx("listProjectIdentities", async (tx) => { + const data = await tx.getResourceData(projectListRid, true); + const projects: ProjectIdentityWithLabel[] = []; + + for (const f of data.fields) { + if (isNullSignedResourceId(f.value)) continue; + + const metaStr = await tx.getKValueStringIfExists(f.value, ProjectMetaKey); + const meta: ProjectMeta = metaStr ? JSON.parse(metaStr) : { label: "(unknown)" }; + + projects.push({ + id: resourceIdToString(f.value), + rid: f.value, + fieldName: f.name, + label: meta.label, + }); + } + + return projects; + }); +} + /** Get detailed info about a project. */ export async function getProjectInfo( pl: PlClient, @@ -211,6 +254,67 @@ export async function deleteProject( }); } +/** One project moved by {@link moveProjects}, as it ended up in the target root. */ +export interface MovedProject { + id: string; + /** Label the project had in the source root. */ + sourceLabel: string; + /** Label it carries in the target root — differs when the name collided and was deduplicated. */ + targetLabel: string; +} + +/** + * Re-attaches projects from one user's project list to another's, keeping the same project + * resources — no copy is made, so nothing is duplicated and no data is rewritten. + * + * This works across roots because the backend permits a reference between differently coloured + * resources when the caller holds write access to both, which admin credentials do. Names that + * collide in the target are deduplicated the way {@link duplicateProject} callers deduplicate + * theirs, so a move never silently shadows a project the target user already has. + * + * The whole batch is one transaction: either every project lands in the target root, or none + * moves and both roots are untouched. + */ +export async function moveProjects( + pl: PlClient, + sourceProjectListRid: SignedResourceId, + targetProjectListRid: SignedResourceId, + projects: ProjectIdentity[], +): Promise { + if (projects.length === 0) return []; + + return await pl.withWriteTx("moveProjects", async (tx) => { + const takenLabels = await getExistingLabelsInTx(tx, targetProjectListRid); + const moved: MovedProject[] = []; + + for (const project of projects) { + const metaStr = await tx.getKValueString(project.rid, ProjectMetaKey); + const meta: ProjectMeta = JSON.parse(metaStr); + const sourceLabel = meta.label; + + let targetLabel = sourceLabel; + if (takenLabels.includes(targetLabel)) { + targetLabel = deduplicateName(sourceLabel, takenLabels); + tx.setKValue(project.rid, ProjectMetaKey, JSON.stringify({ ...meta, label: targetLabel })); + tx.setKValue(project.rid, ProjectLastModifiedTimestamp, String(Date.now())); + } + // Reserved even when the label was free, so two source projects with the same name do not + // both keep it. + takenLabels.push(targetLabel); + + // Attached to the target before being detached from the source: the project keeps a + // reference throughout, so it is never momentarily unreferenced. + tx.createField(field(targetProjectListRid, randomUUID()), "Dynamic", project.rid); + tx.removeField(field(sourceProjectListRid, project.fieldName)); + + moved.push({ id: project.id, sourceLabel, targetLabel }); + } + + await tx.commit(); + return moved; + }); +} + /** Get the project list ResourceId for the connected user. */ export async function getProjectListRid(pl: PlClient): Promise { return await pl.withReadTx("getProjectList", async (tx) => { @@ -225,6 +329,68 @@ export async function getProjectListRid(pl: PlClient): Promise }); } +/** + * Resolves a user's root and, if they have one, their project list. + * + * Unlike {@link navigateToUserRoot} a missing project list is not an error here: a user who has + * never opened the app has a root and no list, and that account is still a legitimate target for + * whole-account operations. Callers that need a list to write into use + * {@link ensureUserProjectList}. + */ +export async function openUserRoot( + pl: PlClient, + username: string, +): Promise<{ userRoot: SignedResourceId; projectListRid: SignedResourceId | undefined }> { + let userRoot: SignedResourceId | undefined; + try { + userRoot = await pl.getUserRoot({ login: username }); + } catch (e) { + // The backend reports an unknown login as NOT_FOUND on the root, which reads as a missing + // resource rather than a missing user. Name the actual problem. + if (isNotFoundError(e)) { + throw new Error(`User "${username}" not found on this server.`); + } + throw e; + } + if (userRoot === undefined) { + throw new Error(`User "${username}" not found on this server (no root resource).`); + } + + return await pl.withReadTx("openUserRoot", async (tx) => { + const projectsField = await tx.getFieldIfExists(field(userRoot, ProjectsField)); + if (projectsField === undefined || isNullSignedResourceId(projectsField.value)) { + return { userRoot, projectListRid: undefined }; + } + return { userRoot, projectListRid: projectsField.value }; + }); +} + +/** + * Returns a user's project list, creating an empty one when the root has none — the same lazy + * creation the middle layer performs for the signed-in user, but against an impersonated root, so + * projects can be re-homed to a user who has never opened the app. + */ +export async function ensureUserProjectList( + pl: PlClient, + userRoot: SignedResourceId, +): Promise { + return await pl.withWriteTx("ensureUserProjectList", async (tx) => { + const projectsField = field(userRoot, ProjectsField); + tx.createField(projectsField, "Dynamic"); + const fieldData = await tx.getField(projectsField); + if (!isNullSignedResourceId(fieldData.value)) { + await tx.commit(); + return fieldData.value; + } + + const list = tx.createEphemeral(ProjectsResourceType); + tx.lock(list); + tx.setField(projectsField, list); + await tx.commit(); + return await list.globalId; + }); +} + /** * Navigates to a specific user's project list resource ID. * Tries ListUserResources first (new backend), falls back to SHA256 named resource lookup.