diff --git a/trustgraph-base/trustgraph/base/iam_client.py b/trustgraph-base/trustgraph/base/iam_client.py index 3ebbaa92..5cfda7c8 100644 --- a/trustgraph-base/trustgraph/base/iam_client.py +++ b/trustgraph-base/trustgraph/base/iam_client.py @@ -201,6 +201,16 @@ class IamClient(RequestResponse): timeout=timeout, ) + async def delete_user(self, workspace, user_id, actor="", + timeout=IAM_TIMEOUT): + await self._request( + operation="delete-user", + workspace=workspace, + actor=actor, + user_id=user_id, + timeout=timeout, + ) + async def create_workspace(self, workspace_record, actor="", timeout=IAM_TIMEOUT): resp = await self._request( diff --git a/trustgraph-cli/pyproject.toml b/trustgraph-cli/pyproject.toml index 6fd735e3..728079c8 100644 --- a/trustgraph-cli/pyproject.toml +++ b/trustgraph-cli/pyproject.toml @@ -46,6 +46,7 @@ tg-create-user = "trustgraph.cli.create_user:main" tg-list-users = "trustgraph.cli.list_users:main" tg-disable-user = "trustgraph.cli.disable_user:main" tg-enable-user = "trustgraph.cli.enable_user:main" +tg-delete-user = "trustgraph.cli.delete_user:main" tg-change-password = "trustgraph.cli.change_password:main" tg-reset-password = "trustgraph.cli.reset_password:main" tg-create-api-key = "trustgraph.cli.create_api_key:main" diff --git a/trustgraph-cli/trustgraph/cli/delete_user.py b/trustgraph-cli/trustgraph/cli/delete_user.py new file mode 100644 index 00000000..dbdf7877 --- /dev/null +++ b/trustgraph-cli/trustgraph/cli/delete_user.py @@ -0,0 +1,62 @@ +""" +Delete a user. Removes the user record, their username lookup, +and all their API keys. The freed username becomes available for +re-use. + +Irreversible. Use tg-disable-user if you want to preserve the +record (audit trail, username squatting protection). +""" + +import argparse + +from ._iam import DEFAULT_URL, DEFAULT_TOKEN, call_iam, run_main + + +def do_delete_user(args): + if not args.yes: + confirm = input( + f"Delete user {args.user_id}? This is irreversible. " + f"[type 'yes' to confirm]: " + ) + if confirm.strip() != "yes": + print("Aborted.") + return + + req = {"operation": "delete-user", "user_id": args.user_id} + if args.workspace: + req["workspace"] = args.workspace + call_iam(args.api_url, args.token, req) + print(f"Deleted user {args.user_id}") + + +def main(): + parser = argparse.ArgumentParser( + prog="tg-delete-user", description=__doc__, + ) + parser.add_argument( + "-u", "--api-url", default=DEFAULT_URL, + help=f"API URL (default: {DEFAULT_URL})", + ) + parser.add_argument( + "-t", "--token", default=DEFAULT_TOKEN, + help="Auth token (default: $TRUSTGRAPH_TOKEN)", + ) + parser.add_argument( + "--user-id", required=True, help="User id to delete", + ) + parser.add_argument( + "-w", "--workspace", default=None, + help=( + "Target workspace (admin only; defaults to caller's " + "assigned workspace)" + ), + ) + parser.add_argument( + "--yes", action="store_true", + help="Skip the interactive confirmation prompt", + ) + run_main(do_delete_user, parser) + + +if __name__ == "__main__": + main() diff --git a/trustgraph-flow/trustgraph/iam/service/iam.py b/trustgraph-flow/trustgraph/iam/service/iam.py index 57e23e06..6e7c7aa5 100644 --- a/trustgraph-flow/trustgraph/iam/service/iam.py +++ b/trustgraph-flow/trustgraph/iam/service/iam.py @@ -236,6 +236,8 @@ class IamService: return await self.handle_disable_user(v) if op == "enable-user": return await self.handle_enable_user(v) + if op == "delete-user": + return await self.handle_delete_user(v) if op == "create-workspace": return await self.handle_create_workspace(v) if op == "list-workspaces": @@ -730,6 +732,46 @@ class IamService: ) return IamResponse() + async def handle_delete_user(self, v): + """Hard-delete a user. Removes the ``iam_users`` row, the + ``iam_users_by_username`` lookup row, and every API key + belonging to the user. + + Unlike disable, this frees the username for re-use and + removes the user's personal data from storage (intended to + cover GDPR erasure-style requirements). When audit logging + lands, the decision to delete vs. anonymise referenced audit + rows will need to be revisited.""" + if not v.workspace: + return _err("invalid-argument", "workspace required") + if not v.user_id: + return _err("invalid-argument", "user_id required") + + user_row, err = await self._user_in_workspace( + v.user_id, v.workspace, + ) + if err is not None: + return err + + # user_row indices match get_user columns. Username is [2]. + username = user_row[2] + + # Revoke all API keys. + key_rows = await self.table_store.list_api_keys_by_user(v.user_id) + for kr in key_rows: + await self.table_store.delete_api_key(kr[0]) + + # Remove username lookup. + if username: + await self.table_store.delete_username_lookup( + v.workspace, username, + ) + + # Remove user record. + await self.table_store.delete_user(v.user_id) + + return IamResponse() + # ------------------------------------------------------------------ # Workspace CRUD # ------------------------------------------------------------------ diff --git a/trustgraph-flow/trustgraph/tables/iam.py b/trustgraph-flow/trustgraph/tables/iam.py index 0f4966fe..3d41ebbd 100644 --- a/trustgraph-flow/trustgraph/tables/iam.py +++ b/trustgraph-flow/trustgraph/tables/iam.py @@ -180,6 +180,9 @@ class IamTableStore: DELETE FROM iam_users_by_username WHERE workspace = ? AND username = ? """) + self.delete_user_stmt = c.prepare(""" + DELETE FROM iam_users WHERE id = ? + """) self.put_api_key_stmt = c.prepare(""" INSERT INTO iam_api_keys ( @@ -301,6 +304,17 @@ class IamTableStore: self.cassandra, self.list_users_by_workspace_stmt, (workspace,), ) + async def delete_user(self, id): + await async_execute( + self.cassandra, self.delete_user_stmt, (id,), + ) + + async def delete_username_lookup(self, workspace, username): + await async_execute( + self.cassandra, self.delete_username_lookup_stmt, + (workspace, username), + ) + # ------------------------------------------------------------------ # API keys # ------------------------------------------------------------------