Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/pl-cli-delete-user.md
Original file line number Diff line number Diff line change
@@ -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 <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 <user>` 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.
36 changes: 36 additions & 0 deletions lib/node/pl-client/proto/plapi/plapiproto/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -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 {}

Expand Down
9 changes: 9 additions & 0 deletions lib/node/pl-client/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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<UserDeletionReport> {
return await this.userResources.deleteUser(login);
}

/**
* Returns the user root SignedResourceId via ListUserResources.
* @param opts.login - target user login; omit for the authenticated user.
Expand Down
14 changes: 14 additions & 0 deletions lib/node/pl-client/src/core/ll_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<grpcTypes.AuthAPI_DeleteUser_Response> {
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<void> {
const cl = this.clientProvider.get();
if (cl instanceof GrpcPlApiClient) {
Expand Down
35 changes: 35 additions & 0 deletions lib/node/pl-client/src/core/user_resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<UserDeletionReport> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -290,6 +292,19 @@ export interface IPlatformClient {
* @generated from protobuf rpc: ListUsers
*/
listUsers(input: AuthAPI_ListUsers_Request, options?: RpcOptions): UnaryCall<AuthAPI_ListUsers_Request, 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.
*
* @generated from protobuf rpc: DeleteUser
*/
deleteUser(input: AuthAPI_DeleteUser_Request, options?: RpcOptions): UnaryCall<AuthAPI_DeleteUser_Request, AuthAPI_DeleteUser_Response>;
/**
*
* Other stuff
Expand Down Expand Up @@ -626,6 +641,22 @@ export class PlatformClient implements IPlatformClient, ServiceInfo {
const method = this.methods[34], opt = this._transport.mergeOptions(options);
return stackIntercept<AuthAPI_ListUsers_Request, AuthAPI_ListUsers_Response>("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<AuthAPI_DeleteUser_Request, AuthAPI_DeleteUser_Response> {
const method = this.methods[35], opt = this._transport.mergeOptions(options);
return stackIntercept<AuthAPI_DeleteUser_Request, AuthAPI_DeleteUser_Response>("unary", this._transport, method, opt, input);
}
/**
*
* Other stuff
Expand All @@ -634,7 +665,7 @@ export class PlatformClient implements IPlatformClient, ServiceInfo {
* @generated from protobuf rpc: ListResourceTypes
*/
listResourceTypes(input: MiscAPI_ListResourceTypes_Request, options?: RpcOptions): UnaryCall<MiscAPI_ListResourceTypes_Request, MiscAPI_ListResourceTypes_Response> {
const method = this.methods[35], opt = this._transport.mergeOptions(options);
const method = this.methods[36], opt = this._transport.mergeOptions(options);
return stackIntercept<MiscAPI_ListResourceTypes_Request, MiscAPI_ListResourceTypes_Response>("unary", this._transport, method, opt, input);
}
/**
Expand All @@ -645,14 +676,14 @@ export class PlatformClient implements IPlatformClient, ServiceInfo {
* @generated from protobuf rpc: Ping
*/
ping(input: MaintenanceAPI_Ping_Request, options?: RpcOptions): UnaryCall<MaintenanceAPI_Ping_Request, MaintenanceAPI_Ping_Response> {
const method = this.methods[36], opt = this._transport.mergeOptions(options);
const method = this.methods[37], opt = this._transport.mergeOptions(options);
return stackIntercept<MaintenanceAPI_Ping_Request, MaintenanceAPI_Ping_Response>("unary", this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: License
*/
license(input: MaintenanceAPI_License_Request, options?: RpcOptions): UnaryCall<MaintenanceAPI_License_Request, MaintenanceAPI_License_Response> {
const method = this.methods[37], opt = this._transport.mergeOptions(options);
const method = this.methods[38], opt = this._transport.mergeOptions(options);
return stackIntercept<MaintenanceAPI_License_Request, MaintenanceAPI_License_Response>("unary", this._transport, method, opt, input);
}
}
Loading
Loading