Skip to content
Merged
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
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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

Expand Down Expand Up @@ -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 <id>
--allowed-scope <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 '<scope>'` as the cue to
Expand All @@ -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/)
Expand All @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion campus_cli/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
336 changes: 336 additions & 0 deletions campus_cli/auth/user.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading