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.