From 85952b71e7e9f45b6759fb011595bbacbc1e11ff Mon Sep 17 00:00:00 2001 From: JS Date: Sat, 10 Oct 2026 00:03:28 +0800 Subject: [PATCH] feat(cli): campus user * management commands (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user sub-app: list/get/new/activate/edit/delete, backed by api.auth_users (campus_python.auth.v1.users). Each command's help names the users:* scope it requires — list/get: users:read, activate: users:mod, new/edit: users:write, delete: users:admin — and delete goes through the destructive-command confirmation (TTY-safe, --confirm or CAMPUS_ASSUME_YES). All commands support --dry-run like client/vault. Requires the server-side users:* vocabulary (nyjc-computing/campus#873) and Users.User.update() (nyjc-computing/campus-api-python#105) for edit. README documents the users scope tiers, the per-vocabulary designation env vars (invariant A8), and dry-run/confirmation coverage. --- README.md | 20 ++- campus_cli/auth/__init__.py | 3 +- campus_cli/auth/user.py | 336 ++++++++++++++++++++++++++++++++++++ campus_cli/cli.py | 2 + tests/unit/test_user.py | 54 ++++++ 5 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 campus_cli/auth/user.py create mode 100644 tests/unit/test_user.py diff --git a/README.md b/README.md index 45ac18f..4e8f1f7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ campus client new --name "My App" --description "My application" # Generate the client's secret (only time it is shown) campus client revoke --client-id +# Manage users (requires users:* scopes and users-admin designation) +campus user list +campus user new --email "new.member@school.edu" --name "New Member" +campus user activate --user-id "new.member@school.edu" + # List vault entries campus vault list --vault myvault @@ -58,15 +63,20 @@ The semantics are exact-request, not Google-style accumulation: campus invariant A2 (no silent widening). `campus auth refresh` reissues exactly the granted scopes and never widens them. - **Management scopes are monotonic**: `clients:admin` ⊇ - `clients:write` ⊇ `clients:read`, so request only the highest - tier you need. + `clients:write` ⊇ `clients:read`, and `users:admin` ⊇ + `users:write` ⊇ `users:mod` ⊇ `users:read`, so request only the + highest tier you need. The users tiers map to commands: read + lists/gets, mod activates, write creates/renames, admin deletes. - **The client allowlist is fail-closed.** A scope outside the CLI client's registered `allowed_scopes` fails the login outright with `invalid_scope`; an operator must widen the allowlist first (`campus client update --client-id --allowed-scope `). Carrying a management scope is still not enough on its own — the authorizing account must also be a - designated admin user server-side (campus invariant A8). + designated admin user server-side (campus invariant A8): clients + scopes consult `AUTH_ADMIN_USER_IDS`, users scopes consult + `AUTH_USERS_ADMIN_USER_IDS` (a clients-admin is not a + users-admin). For scripts and agents: request the full scope set you will need up front, and treat a 403 `Token lacks ''` as the cue to @@ -82,7 +92,7 @@ scopes never self-confer). ### Dry-run mode -All `client` and `vault` commands accept `--dry-run`. Instead of +All `client`, `user` and `vault` commands accept `--dry-run`. Instead of authenticating and calling the API, the command prints the equivalent Python snippet using the [campus-api-python](https://pypi.org/project/campus-api-python/) @@ -104,7 +114,7 @@ print(client.id) ### Non-interactive use (scripts, CI, agents) Destructive commands — `auth logout`, `client delete`, `client revoke`, -`vault delete` — confirm before acting. When no terminal is attached +`user delete`, `vault delete` — confirm before acting. When no terminal is attached the CLI never blocks waiting for input: it refuses immediately with an error naming the remedy. Waive the confirmation per command with `--confirm`/`-y`, or for a whole session with the `CAMPUS_ASSUME_YES` diff --git a/campus_cli/auth/__init__.py b/campus_cli/auth/__init__.py index 4dbaa86..ecfbc58 100644 --- a/campus_cli/auth/__init__.py +++ b/campus_cli/auth/__init__.py @@ -2,6 +2,7 @@ from campus_cli.auth.client import client_app from campus_cli.auth.login import login_app +from campus_cli.auth.user import user_app from campus_cli.auth.vault import vault_app -__all__ = ["client_app", "login_app", "vault_app"] +__all__ = ["client_app", "login_app", "user_app", "vault_app"] diff --git a/campus_cli/auth/user.py b/campus_cli/auth/user.py new file mode 100644 index 0000000..1cf7d73 --- /dev/null +++ b/campus_cli/auth/user.py @@ -0,0 +1,336 @@ +"""User management commands (#42). + +Every command requires the matching users:* management scope on the +token (login with `campus auth login --scope users:...`) AND the +account to be listed in the deployment's AUTH_USERS_ADMIN_USER_IDS +(campus invariant A8, per-vocabulary designation): + +- list/get: users:read +- activate: users:mod (implies read) +- new/edit: users:write (implies mod and read) +- delete: users:admin (implies write) +""" + +import typer +from rich.console import Console + +from campus_cli.auth.common import ( + confirm_destructive, + dry_run_option, + get_api_client, +) +from campus_cli.utils.output import ( + print_error, + print_json, + print_python_api, + print_success, +) + +user_app = typer.Typer(help="User management commands") +console = Console() + + +def _iso(value) -> str | None: + """Render a datetime-or-ISO-string field verbatim as a string.""" + return value.isoformat() if hasattr(value, "isoformat") else value + + +def _format_user(user) -> dict: + """Format a user model for output. + + Args: + user: The campus.model.User instance + + Returns: + Dict representation of the user + """ + return { + "id": user.id, + "email": user.email, + "name": user.name, + "created_at": _iso(user.created_at), + "activated_at": _iso(user.activated_at), + } + + +def _print_user_details(user_data: dict) -> None: + """Print user details in a formatted way. + + Args: + user_data: Dict with user information + """ + console.print(f"[bold]User ID:[/bold] {user_data.get('id', 'N/A')}") + console.print(f"[bold]Email:[/bold] {user_data.get('email', 'N/A')}") + console.print(f"[bold]Name:[/bold] {user_data.get('name', 'N/A')}") + if user_data.get("created_at"): + console.print(f"[bold]Created:[/bold] {user_data['created_at']}") + activated_at = user_data.get("activated_at") + if activated_at: + console.print(f"[bold]Activated:[/bold] {activated_at}") + else: + console.print("[bold]Status:[/bold] [yellow]inactive[/yellow]") + + +@user_app.command("list") +def user_list( + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + dry_run: bool = dry_run_option(), +) -> None: + """ + List all Campus users. + + Requires the users:read scope (login with + `campus auth login --scope users:read`). + """ + if dry_run: + print_python_api( + "campus user list", + [ + "users = campus.auth.users.list()", + "for user in users:", + " print(user.id, user.name)", + ], + ) + return + + try: + api = get_api_client() + users = api.auth_users.list() + + if output_json: + print_json([_format_user(u) for u in users]) + else: + if not users: + console.print("[dim]No users found.[/dim]") + return + + console.print(f"[bold]Found {len(users)} user(s):[/bold]\n") + for user in users: + formatted = _format_user(user) + console.print(f"[cyan]{formatted['id']}[/cyan]") + console.print(f" Name: {formatted['name']}") + status = ( + "active" + if formatted["activated_at"] + else "[yellow]inactive[/yellow]" + ) + console.print(f" Status: {status}") + console.print() + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to list users: {e}") + raise typer.Exit(1) from e + + +@user_app.command("new") +def user_new( + email: str = typer.Option( + ..., "--email", "-e", help="User email (also the user ID)" + ), + name: str = typer.Option(..., "--name", "-n", help="User display name"), + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Create a new Campus user. + + Requires the users:write scope (login with + `campus auth login --scope users:write`). The user starts + inactive; activate them with 'campus user activate'. + """ + if dry_run: + print_python_api( + "campus user new", + [ + "user = campus.auth.users.new(", + f" email={email!r},", + f" name={name!r},", + ")", + "print(user.id)", + ], + ) + return + + try: + api = get_api_client() + user = api.auth_users.new(email=email, name=name) + + result = _format_user(user) + + if output_json: + print_json(result) + else: + print_success(f"User '{email}' created successfully!") + _print_user_details(result) + console.print( + "\n[dim]New users start inactive: activate with" + " 'campus user activate'.[/dim]" + ) + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to create user: {e}") + raise typer.Exit(1) from e + + +@user_app.command("get") +def user_get( + user_id: str = typer.Option(..., "--user-id", "-u", help="User ID (email)"), + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Get details of a Campus user. + + Requires the users:read scope (login with + `campus auth login --scope users:read`). + """ + if dry_run: + print_python_api( + "campus user get", + [ + f"user = campus.auth.users[{user_id!r}].get()", + "print(user.name, user.activated_at)", + ], + ) + return + + try: + api = get_api_client() + user = api.auth_users[user_id].get() + + result = _format_user(user) + + if output_json: + print_json(result) + else: + _print_user_details(result) + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to get user: {e}") + raise typer.Exit(1) from e + + +@user_app.command("activate") +def user_activate( + user_id: str = typer.Option(..., "--user-id", "-u", help="User ID (email)"), + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Activate a Campus user. + + Requires the users:mod scope or higher (login with + `campus auth login --scope users:mod`). + """ + if dry_run: + print_python_api( + "campus user activate", + [ + f"user = campus.auth.users[{user_id!r}].activate()", + "print(user.activated_at)", + ], + ) + return + + try: + api = get_api_client() + user = api.auth_users[user_id].activate() + + result = _format_user(user) + + if output_json: + print_json(result) + else: + print_success(f"User '{user_id}' activated successfully!") + _print_user_details(result) + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to activate user: {e}") + raise typer.Exit(1) from e + + +@user_app.command("edit") +def user_edit( + user_id: str = typer.Option(..., "--user-id", "-u", help="User ID (email)"), + name: str = typer.Option(..., "--name", "-n", help="New display name"), + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Rename a Campus user. + + Requires the users:write scope (login with + `campus auth login --scope users:write`). Name is the only + editable field: a user's ID IS its email, so identity fields + stay fixed. + """ + if dry_run: + print_python_api( + "campus user edit", + [ + f"user = campus.auth.users[{user_id!r}].update(", + f" name={name!r},", + ")", + "print(user.name)", + ], + ) + return + + try: + api = get_api_client() + user = api.auth_users[user_id].update(name=name) + + result = _format_user(user) + + if output_json: + print_json(result) + else: + print_success(f"User '{user_id}' updated successfully!") + _print_user_details(result) + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to update user: {e}") + raise typer.Exit(1) from e + + +@user_app.command("delete") +def user_delete( + user_id: str = typer.Option(..., "--user-id", "-u", help="User ID (email)"), + confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Delete a Campus user. + + Permanently deletes the user record. Requires the users:admin + scope (login with `campus auth login --scope users:admin`). + """ + if dry_run: + print_python_api( + "campus user delete", + [f"campus.auth.users[{user_id!r}].delete()"], + ) + return + + confirm_destructive(f"delete user '{user_id}'", confirm) + + try: + api = get_api_client() + api.auth_users[user_id].delete() + print_success(f"User '{user_id}' deleted successfully.") + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to delete user: {e}") + raise typer.Exit(1) from e diff --git a/campus_cli/cli.py b/campus_cli/cli.py index d39a589..010783e 100644 --- a/campus_cli/cli.py +++ b/campus_cli/cli.py @@ -40,12 +40,14 @@ def version() -> None: from campus_cli.auth import ( # noqa: E402 client_app, login_app, + user_app, vault_app, ) # Register sub-apps app.add_typer(login_app, name="auth", help="Authentication commands") app.add_typer(client_app, name="client", help="OAuth client management commands") +app.add_typer(user_app, name="user", help="User management commands") app.add_typer(vault_app, name="vault", help="Vault management commands") diff --git a/tests/unit/test_user.py b/tests/unit/test_user.py new file mode 100644 index 0000000..4d3fc3c --- /dev/null +++ b/tests/unit/test_user.py @@ -0,0 +1,54 @@ +"""Unit tests for user output formatting (#42).""" + +from datetime import datetime, timezone +from types import SimpleNamespace + +from campus_cli.auth.user import _format_user + + +def _user(created_at, activated_at=None, **overrides): + fields = { + "id": "user@example.com", + "email": "user@example.com", + "name": "Test User", + "created_at": created_at, + "activated_at": activated_at, + } + fields.update(overrides) + return SimpleNamespace(**fields) + + +def test_format_user_with_datetime_fields(): + """Locally-constructed models carry datetimes.""" + created = datetime(2026, 9, 29, 10, 0, tzinfo=timezone.utc) + activated = datetime(2026, 10, 1, 8, 30, tzinfo=timezone.utc) + result = _format_user(_user(created, activated)) + assert result["created_at"] == "2026-09-29T10:00:00+00:00" + assert result["activated_at"] == "2026-10-01T08:30:00+00:00" + + +def test_format_user_with_string_fields(): + """from_resource models carry the timestamps as ISO strings.""" + result = _format_user(_user( + "2026-09-29T10:00:00+00:00", + "2026-10-01T08:30:00+00:00", + )) + assert result["created_at"] == "2026-09-29T10:00:00+00:00" + assert result["activated_at"] == "2026-10-01T08:30:00+00:00" + + +def test_format_user_inactive_has_none_activated_at(): + """New users are inactive: activated_at projects as None.""" + result = _format_user(_user("2026-09-29T10:00:00+00:00")) + assert result["activated_at"] is None + + +def test_format_user_carries_identity_fields(): + """The id IS the email: both project verbatim.""" + result = _format_user(_user( + None, email="other@example.com", id="other@example.com" + )) + assert result["id"] == "other@example.com" + assert result["email"] == "other@example.com" + assert result["name"] == "Test User" + assert result["created_at"] is None