diff --git a/README.md b/README.md index f779a7d..da7bec4 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,16 @@ 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) +# Manage users (needs a users:* grant row AND the matching scope) campus user list campus user new --email "new.member@school.edu" --name "New Member" campus user activate --user-id "new.member@school.edu" +# See — and administer — who can do what (#883 grant store) +campus grant list +campus grant grant --grantee-type user --grantee-id someone@school.edu \ + --resource-type users --level write + # List vault entries campus vault list --vault myvault @@ -78,23 +83,33 @@ The semantics are exact-request, not Google-style accumulation: `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. +- **Management authority = grant row AND scope.** Carrying a + management scope is only half the story: the authorizing account + must also hold a matching row in the access-grant store — + `campus grant list` shows the matrix, `campus grant grant/revoke` + administers it (operator or a same-vocabulary admin only; no + principal administers its own grants, and vocabulary-level + `clients:admin` rows are rejected). Clients grant rows cap at + write: registering or deleting clients stays operator/root-only + (campus invariant A8, nyjc-computing/campus#883). +- **The super-admin root is the one exception** (A8 carve-out): the + env-nominated `AUTH_SUPER_ADMIN` account holds full authority with + no grant rows and no scope requirement. Safeguard it; use it to + seed grants, then work day-to-day on a sub-admin account. - **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): clients - scopes consult `AUTH_ADMIN_USER_IDS`, users scopes consult - `AUTH_USERS_ADMIN_USER_IDS` (a clients-admin is not a - users-admin). + --allowed-scope `). **Check before you re-login:** `campus auth status` reports the scopes the stored token carries (along with the client, endpoints, principal and expiry), so scripts and agents can answer "what can this token do?" before hitting a 403. Use `--json` for machine-readable output; `--offline` skips the validity check when -no network is wanted. Scopes are recorded locally at login — +no network is wanted. It also shows your grant rows when your +account may read them (operator, root, or grants admin). Scopes are +recorded locally at login — credentials stored before this was recorded show them as unknown until the next login. `campus auth status` exits 1 when you are not logged in or the server rejects the stored token, and 0 otherwise diff --git a/campus_cli/api.py b/campus_cli/api.py index ee47b6c..94f1a81 100644 --- a/campus_cli/api.py +++ b/campus_cli/api.py @@ -62,6 +62,11 @@ def auth_users(self): """Access the auth users resource.""" return self.campus.auth.users + @property + def auth_grants(self): + """Access the auth access-grant store resource.""" + return self.campus.auth.grants + @property def api_timetables(self): """Access the API timetable resource.""" diff --git a/campus_cli/auth/__init__.py b/campus_cli/auth/__init__.py index ecfbc58..b115ed4 100644 --- a/campus_cli/auth/__init__.py +++ b/campus_cli/auth/__init__.py @@ -1,8 +1,9 @@ """Authentication commands for Campus CLI.""" from campus_cli.auth.client import client_app +from campus_cli.auth.grants import grants_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", "user_app", "vault_app"] +__all__ = ["client_app", "grants_app", "login_app", "user_app", "vault_app"] diff --git a/campus_cli/auth/client.py b/campus_cli/auth/client.py index 59059b6..27e174d 100644 --- a/campus_cli/auth/client.py +++ b/campus_cli/auth/client.py @@ -195,6 +195,9 @@ def client_new( By default the client is confidential: the client secret is NOT returned; use 'campus client revoke' to generate and retrieve it. Public clients (--public) have no client secret at all. + + Authority: operator or super-admin root — client registration is + not grantable through the access-grant store (campus#883). """ if dry_run: new_kwargs = [ @@ -361,6 +364,11 @@ def client_update( stored value: the API layer full-replaces each provided field, so re-pass every entry the client must keep. Fields you omit are left untouched. + + Authority: your own client's profile fields are self-service; + other clients need clients:write authority (grant row plus scope); + the scope caps and token-bridge flag stay operator/root-only + (campus#883 — clients grant rows cap at write). """ if clear_allowed_scopes and allowed_scope: print_error( @@ -455,6 +463,8 @@ def client_delete( Delete an OAuth client. Permanently deletes the specified OAuth client. + + Authority: operator or super-admin root (campus#883). """ if dry_run: print_python_api( @@ -490,6 +500,9 @@ def client_revoke( Revokes the current client secret and generates a new one. The new secret will be displayed (this is the only way to retrieve it). Use this when first creating a client or when you need to rotate the secret. + + Authority: the client itself (own secret), or the operator / + super-admin root (campus#883). """ if dry_run: print_python_api( diff --git a/campus_cli/auth/grants.py b/campus_cli/auth/grants.py new file mode 100644 index 0000000..b43228e --- /dev/null +++ b/campus_cli/auth/grants.py @@ -0,0 +1,410 @@ +"""Access-grant store commands (#50). + +The grant matrix (#883) lives server-side in `/auth/v1/grants/`: rows +keyed by (grantee_type, grantee_id, resource_type[, resource_id]) +holding either a management level (read < mod < write < admin) or +vault bitflags (1=READ, 2=CREATE, 4=UPDATE, 8=DELETE). A user +principal's management authority is grant row AND token scope; the +env-nominated super-admin root needs neither. + +Administration authority: the operator, a same-vocabulary admin, or +the root. Anti-escalation: no principal administers its own grants, +and vocabulary-level `clients:admin` rows are rejected +(operator-equivalent). +""" + +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, +) + +grants_app = typer.Typer(help="Access-grant store commands (who can do what)") +console = Console() + +# Vault bitflag names (campus.model.client.ClientAccess ordering, as +# documented by the client access commands). +_BITFLAG_NAMES = {1: "READ", 2: "CREATE", 4: "UPDATE", 8: "DELETE"} + + +def _format_access(row: dict) -> str: + """Render a grant row's permission as `resource:level` or bitflags.""" + resource = row.get("resource_type", "?") + if row.get("resource_id"): + resource = f"{resource} {row['resource_id']}" + if row.get("level"): + return f"{resource}:{row['level']}" + bits = row.get("bits") or 0 + names = [name for bit, name in _BITFLAG_NAMES.items() if bits & bit] + return f"{resource}: {', '.join(names) if names else str(bits)}" + + +def _format_grant(row: dict) -> dict: + """Format a grant row for output. + + Args: + row: The server's AccessGrant resource (plain dict) + + Returns: + Dict representation with a human-readable access summary + """ + return { + "id": row.get("id"), + "grantee": f"{row.get('grantee_type')}: {row.get('grantee_id')}", + "access": _format_access(row), + "created_at": row.get("created_at"), + } + + +def _print_grant_rows(rows: list[dict]) -> None: + """Print grant rows grouped by grantee.""" + if not rows: + console.print("[dim]No grant rows found.[/dim]") + return + console.print(f"[bold]Found {len(rows)} grant row(s):[/bold]\n") + for row in rows: + formatted = _format_grant(row) + console.print(f"[cyan]{formatted['grantee']}[/cyan]") + console.print(f" Access: {formatted['access']}") + console.print(f" [dim]{formatted['id']}[/dim]") + console.print() + + +def _validate_level_bits(level: str | None, bits: int | None) -> None: + """Require exactly one of --level / --bits. + + Args: + level: Management level option value + bits: Vault bitflag option value + + Raises: + typer.Exit: When neither or both are provided. + """ + if (level is None) == (bits is None): + print_error( + "Provide exactly one of --level (management vocabularies:" + " read/mod/write/admin) or --bits (vault bitflags:" + " 1=READ, 2=CREATE, 4=UPDATE, 8=DELETE; combine by summing)." + ) + raise typer.Exit(1) + + +GranteeType = typer.Option( + ..., "--grantee-type", "-t", help="Grantee principal type: user or client" +) +GranteeId = typer.Option(..., "--grantee-id", "-g", help="User email or client ID") +ResourceType = typer.Option( + ..., + "--resource-type", + "-r", + help="Vocabulary: users, clients, or vault (per-label)", +) +ResourceId = typer.Option( + "", "--resource-id", help="Instance for per-label resources (vault label)" +) + + +@grants_app.command("list") +def grants_list( + grantee_type: str | None = typer.Option(None, "--grantee-type", "-t"), + grantee_id: str | None = typer.Option(None, "--grantee-id", "-g"), + resource_type: str | None = typer.Option(None, "--resource-type", "-r"), + resource_id: str | None = typer.Option(None, "--resource-id"), + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + dry_run: bool = dry_run_option(), +) -> None: + """ + List access-grant rows — the "who can administer what" matrix. + + Without filters this is operator/root-only; user principals may + list the single vocabulary they administer. Filter to keep the + answer small. + """ + if dry_run: + print_python_api( + "campus grant list", + [ + "result = campus.auth.grants.list()", + "for row in result['grants']:", + " print(row['grantee_id'], row['resource_type'], row['level'])", + ], + ) + return + + try: + api = get_api_client() + result = api.auth_grants.list( + grantee_type=grantee_type, + grantee_id=grantee_id, + resource_type=resource_type, + resource_id=resource_id or None, + ) + rows = result.get("grants", []) + + if output_json: + print_json(rows) + else: + _print_grant_rows(rows) + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to list grants: {e}") + raise typer.Exit(1) from e + + +@grants_app.command("check") +def grants_check( + grantee_type: str = GranteeType, + grantee_id: str = GranteeId, + resource_type: str = ResourceType, + resource_id: str = ResourceId, + level: str | None = typer.Option(None, "--level", help="Management level to test"), + bits: int | None = typer.Option( + None, "--bits", help="Vault bitflag mask to test" + ), + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Check whether a grant row covers a permission. + + Requires operator or same-vocabulary admin authority. + """ + _validate_level_bits(level, bits) + + if dry_run: + print_python_api( + "campus grant check", + [ + "granted = campus.auth.grants.check(", + f" grantee_type={grantee_type!r},", + f" grantee_id={grantee_id!r},", + f" resource_type={resource_type!r},", + f" resource_id={resource_id!r},", + f" level={level!r}, bits={bits!r},", + ")", + "print(granted)", + ], + ) + return + + try: + api = get_api_client() + granted = api.auth_grants.check( + grantee_type=grantee_type, + grantee_id=grantee_id, + resource_type=resource_type, + resource_id=resource_id, + bits=bits, + level=level, + ) + + if output_json: + print_json({"granted": granted}) + else: + state = "[green]granted[/green]" if granted else "[red]not granted[/red]" + access = _format_access( + { + "resource_type": resource_type, + "resource_id": resource_id, + "level": level, + "bits": bits, + } + ) + console.print(f"{grantee_type} {grantee_id} → {access}: {state}") + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to check grant: {e}") + raise typer.Exit(1) from e + + +@grants_app.command("grant") +def grants_grant( + grantee_type: str = GranteeType, + grantee_id: str = GranteeId, + resource_type: str = ResourceType, + resource_id: str = ResourceId, + level: str | None = typer.Option( + None, "--level", help="Management level: read/mod/write/admin" + ), + bits: int | None = typer.Option( + None, "--bits", help="Vault bitflag mask (1=READ, 2=CREATE, 4=UPDATE, 8=DELETE;" + " sum to combine). Vault bits OR into the existing mask" + ), + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Grant access (create a row, or raise an existing one). + + Management levels never downgrade: revoke first to lower a level. + Anti-escalation: no principal may administer its own grants, and + vocabulary-level clients:admin rows are rejected + (operator-equivalent — authority over client registration stays + with the operator and the root). + """ + _validate_level_bits(level, bits) + if resource_type == "vault" and not resource_id: + print_error("Vault grant rows must name their label (--resource-id).") + raise typer.Exit(1) + + if dry_run: + print_python_api( + "campus grant grant", + [ + "row = campus.auth.grants.grant(", + f" grantee_type={grantee_type!r},", + f" grantee_id={grantee_id!r},", + f" resource_type={resource_type!r},", + f" resource_id={resource_id!r},", + f" level={level!r}, bits={bits!r},", + ")", + "print(row)", + ], + ) + return + + try: + api = get_api_client() + row = api.auth_grants.grant( + grantee_type=grantee_type, + grantee_id=grantee_id, + resource_type=resource_type, + resource_id=resource_id, + bits=bits, + level=level, + ) + + if output_json: + print_json(row) + else: + print_success("Grant created successfully!") + print_json(row) + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to create grant: {e}") + raise typer.Exit(1) from e + + +@grants_app.command("revoke") +def grants_revoke( + grantee_type: str = GranteeType, + grantee_id: str = GranteeId, + resource_type: str = ResourceType, + resource_id: str = ResourceId, + level: str | None = typer.Option( + None, "--level", help="Management level to revoke" + ), + bits: int | None = typer.Option( + None, "--bits", help="Vault bitflags to clear" + ), + output_json: bool = typer.Option(False, "--json", help="Output as JSON"), + confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Revoke access (clear vault bits, delete covered levels). + + Vault rows delete at zero bits; a revoked level's row is deleted + when the held level covers it. The remaining row (or null) is + printed. + """ + _validate_level_bits(level, bits) + + if dry_run: + print_python_api( + "campus grant revoke", + [ + "row = campus.auth.grants.revoke(", + f" grantee_type={grantee_type!r},", + f" grantee_id={grantee_id!r},", + f" resource_type={resource_type!r},", + f" resource_id={resource_id!r},", + f" level={level!r}, bits={bits!r},", + ")", + "print(row)", + ], + ) + return + + target = _format_access( + { + "resource_type": resource_type, + "resource_id": resource_id, + "level": level, + "bits": bits, + } + ) + confirm_destructive(f"revoke {target} from {grantee_type} {grantee_id}", confirm) + + try: + api = get_api_client() + row = api.auth_grants.revoke( + grantee_type=grantee_type, + grantee_id=grantee_id, + resource_type=resource_type, + resource_id=resource_id, + bits=bits, + level=level, + ) + + if output_json: + print_json(row) + elif row: + print_success("Revoked; remaining row:") + print_json(row) + else: + print_success("Revoked; the grant row was deleted.") + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to revoke grant: {e}") + raise typer.Exit(1) from e + + +@grants_app.command("delete") +def grants_delete( + grant_id: str = typer.Option(..., "--grant-id", help="Grant row ID (from list)"), + confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation"), + dry_run: bool = dry_run_option(), +) -> None: + """ + Delete a grant row outright. + + Prefer 'campus grant revoke' for partial revocation: this removes + the whole row. + """ + if dry_run: + print_python_api( + "campus grant delete", + [f"campus.auth.grants.delete({grant_id!r})"], + ) + return + + confirm_destructive(f"delete grant row '{grant_id}'", confirm) + + try: + api = get_api_client() + api.auth_grants.delete(grant_id) + print_success(f"Grant row '{grant_id}' deleted.") + + except typer.Exit: + raise + except Exception as e: + print_error(f"Failed to delete grant: {e}") + raise typer.Exit(1) from e diff --git a/campus_cli/auth/status.py b/campus_cli/auth/status.py index eab9667..fc88884 100644 --- a/campus_cli/auth/status.py +++ b/campus_cli/auth/status.py @@ -39,6 +39,7 @@ from campus_cli.auth import common from campus_cli.auth.common import get_token_status +from campus_cli.auth.grants import _format_access from campus_cli.config import PUBLIC_OAUTH_CLIENT_ID from campus_cli.utils.output import console, print_success, print_warning @@ -94,6 +95,48 @@ def probe_token_validity(auth_url: str, token: str) -> tuple[str, str | None]: return VALIDITY_UNKNOWN, f"server returned HTTP {response.status_code}" +# Note shown when the caller cannot read grant rows: the list +# endpoint is gated to the operator, the root, and grants admins. +GRANTS_UNREADABLE_NOTE = ( + "grant rows are visible to the operator, the root, and grants " + "admins only (campus grant list with authority)" +) + + +def fetch_grant_rows( + auth_url: str, token: str, user_id: str | None, +) -> tuple[list | None, str | None]: + """Fetch the caller's grant rows for the status report (#50). + + GET /grants/ filtered by grantee where the principal is known + (the root may read unfiltered). The list endpoint is gated to + the operator, the root, and same-vocabulary admins, so an + ordinary user's 403 reads as "rows exist but are not visible + here" — never as "no rows". + + Returns: + (rows, note): rows is the grant-row list when readable, else + None with a note explaining why. + """ + query = {"grantee_type": "user"} + if user_id: + query["grantee_id"] = user_id + try: + response = requests.get( + f"{auth_url}/grants/", + headers={"Authorization": f"Bearer {token}"}, + params=query, + timeout=_PROBE_TIMEOUT, + ) + except requests.RequestException as e: + return None, f"could not reach server ({e})" + if response.status_code == 200: + return response.json().get("grants", []), None + if response.status_code in (401, 403): + return None, GRANTS_UNREADABLE_NOTE + return None, f"inconclusive (HTTP {response.status_code})" + + def _expires_in_seconds(expires_at: str | None) -> int | None: """Seconds until the stored expiry, negative once past. @@ -146,7 +189,10 @@ def build_status(offline: bool) -> dict: } if not authenticated: - status.update(token_validity=None, probe_url=None, probe_note=None) + status.update( + token_validity=None, probe_url=None, probe_note=None, + grants=None, grants_note=None, + ) return status if offline: @@ -154,6 +200,8 @@ def build_status(offline: bool) -> dict: token_validity=VALIDITY_NOT_CHECKED, probe_url=None, probe_note=None, + grants=None, + grants_note="not fetched (--offline)", ) return status @@ -164,6 +212,8 @@ def build_status(offline: bool) -> dict: probe_url = token["token_auth_url"] or token["auth_url"] validity, note = probe_token_validity(probe_url, creds.get_token()) status.update(token_validity=validity, probe_url=probe_url, probe_note=note) + rows, grants_note = fetch_grant_rows(probe_url, creds.get_token(), user_id) + status.update(grants=rows, grants_note=grants_note) return status @@ -280,6 +330,22 @@ def _print_human(status: dict) -> None: ) _print_validity_line(status) + _print_grants_line(status) + + +def _print_grants_line(status: dict) -> None: + """Render the caller's grant rows, or why they are not shown.""" + rows = status.get("grants") + if rows is None: + note = status.get("grants_note") + if note: + console.print(f"[dim]Grant rows: {note}[/dim]") + return + if not rows: + console.print("Grant rows: (none)") + return + rendered = ", ".join(_format_access(row) for row in rows) + console.print(f"Grant rows: {rendered}") def run_status(output_json: bool, offline: bool) -> None: diff --git a/campus_cli/auth/user.py b/campus_cli/auth/user.py index 1cf7d73..69b7cd6 100644 --- a/campus_cli/auth/user.py +++ b/campus_cli/auth/user.py @@ -1,9 +1,10 @@ """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): +Authority model (campus#883 grant store): each command needs a +matching grant row AND the matching users:* scope on the token — +either leg alone confers nothing (campus invariant A8). The +env-nominated super-admin root needs neither. Inspect and administer +rows with `campus grant`: - list/get: users:read - activate: users:mod (implies read) @@ -79,8 +80,9 @@ def user_list( """ List all Campus users. - Requires the users:read scope (login with - `campus auth login --scope users:read`). + Requires users:read authority: a users:read grant row AND the + users:read scope on your token (see `campus grant list`; the + super-admin root needs neither). """ if dry_run: print_python_api( @@ -136,9 +138,10 @@ def user_new( """ 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'. + Requires users:write authority: a users:write grant row AND + the users:write scope on your token (see `campus grant list`; + the super-admin root needs neither). The user starts inactive; + activate them with 'campus user activate'. """ if dry_run: print_python_api( @@ -185,8 +188,9 @@ def user_get( """ Get details of a Campus user. - Requires the users:read scope (login with - `campus auth login --scope users:read`). + Requires users:read authority: a users:read grant row AND the + users:read scope on your token (see `campus grant list`; the + super-admin root needs neither). """ if dry_run: print_python_api( @@ -225,8 +229,9 @@ def user_activate( """ Activate a Campus user. - Requires the users:mod scope or higher (login with - `campus auth login --scope users:mod`). + Requires users:mod authority or higher: a users:mod grant row + AND the matching scope on your token (see `campus grant list`; + the super-admin root needs neither). """ if dry_run: print_python_api( @@ -267,10 +272,10 @@ def user_edit( """ 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. + Requires users:write authority: a users:write grant row AND + the users:write scope on your token (see `campus grant list`; + the super-admin root needs neither). Name is the only editable + field: a user's ID IS its email, so identity fields stay fixed. """ if dry_run: print_python_api( @@ -312,8 +317,10 @@ def user_delete( """ Delete a Campus user. - Permanently deletes the user record. Requires the users:admin - scope (login with `campus auth login --scope users:admin`). + Permanently deletes the user record. Requires users:admin + authority: a users:admin grant row AND the users:admin scope on + your token (see `campus grant list`; the super-admin root needs + neither). """ if dry_run: print_python_api( diff --git a/campus_cli/cli.py b/campus_cli/cli.py index 010783e..17c8dc9 100644 --- a/campus_cli/cli.py +++ b/campus_cli/cli.py @@ -39,6 +39,7 @@ def version() -> None: # noqa: E402 - Imported here to avoid circular dependencies from campus_cli.auth import ( # noqa: E402 client_app, + grants_app, login_app, user_app, vault_app, @@ -48,6 +49,7 @@ def version() -> None: 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(grants_app, name="grant", help="Access-grant store commands") app.add_typer(vault_app, name="vault", help="Vault management commands") diff --git a/tests/unit/test_grants.py b/tests/unit/test_grants.py new file mode 100644 index 0000000..f83fdf7 --- /dev/null +++ b/tests/unit/test_grants.py @@ -0,0 +1,81 @@ +"""Unit tests for grant output formatting and option validation (#50).""" + +import pytest +import typer +from typer.testing import CliRunner + +from campus_cli.auth.grants import ( + _format_access, + _format_grant, + _validate_level_bits, +) + +runner = CliRunner() + + +def _row(**overrides): + fields = { + "id": "uid-grant-10131fe6", + "grantee_type": "user", + "grantee_id": "user@example.com", + "resource_type": "users", + "resource_id": "", + "bits": None, + "level": "write", + "created_at": "2026-10-10T15:10:00+00:00", + } + fields.update(overrides) + return fields + + +class TestFormatAccess: + """_format_access renders levels and vault bitflags.""" + + def test_level_row(self): + assert _format_access(_row()) == "users:write" + + def test_vault_row_names_label_and_bits(self): + row = _row( + resource_type="vault", + resource_id="email", + level=None, + bits=3, + ) + assert _format_access(row) == "vault email: READ, CREATE" + + def test_zero_bits_render_as_zero(self): + row = _row(resource_type="vault", resource_id="x", level=None, bits=0) + assert _format_access(row) == "vault x: 0" + + def test_check_shape_without_id(self): + row = {"resource_type": "users", "resource_id": "", "level": "read"} + assert _format_access(row) == "users:read" + + +class TestFormatGrant: + """_format_grant projects the row into display fields.""" + + def test_projects_grantee_and_access(self): + result = _format_grant(_row()) + assert result["id"] == "uid-grant-10131fe6" + assert result["grantee"] == "user: user@example.com" + assert result["access"] == "users:write" + assert result["created_at"] == "2026-10-10T15:10:00+00:00" + + +class TestValidateLevelBits: + """Exactly one of --level / --bits must be provided.""" + + def test_neither_exits(self): + with pytest.raises(typer.Exit): + _validate_level_bits(None, None) + + def test_both_exit(self): + with pytest.raises(typer.Exit): + _validate_level_bits("write", 1) + + def test_level_alone_passes(self): + _validate_level_bits("write", None) + + def test_bits_alone_passes(self): + _validate_level_bits(None, 3) diff --git a/tests/unit/test_status.py b/tests/unit/test_status.py index 306fe69..6651fb3 100644 --- a/tests/unit/test_status.py +++ b/tests/unit/test_status.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta, timezone from unittest import mock +import pytest import requests from typer.testing import CliRunner @@ -25,6 +26,18 @@ runner = CliRunner() +@pytest.fixture(autouse=True) +def _no_grant_fetch(monkeypatch): + """Default-stub the grants readout (#50); row tests re-patch it. + + Without this, every non-offline build_status test would make a + real HTTP call to fetch grant rows. + """ + monkeypatch.setattr( + "campus_cli.auth.status.fetch_grant_rows", lambda *_args, **_kwargs: ([], None) + ) + + def _mock_credentials(**overrides): """Build a credential-store mock describing an authenticated user.""" creds = mock.Mock() @@ -340,6 +353,60 @@ def test_invalid_token_nonzero_in_json_mode_too(self): payload = json.loads(result.stdout) assert payload["token_validity"] == "invalid" + def test_grant_rows_render_in_human_output(self): + """Readable grant rows render as access summaries (#50).""" + rows = [ + {"resource_type": "users", "level": "write", "resource_id": ""}, + {"resource_type": "clients", "level": "write", "resource_id": ""}, + ] + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_VALID, None), + ), + mock.patch( + "campus_cli.auth.status.fetch_grant_rows", + return_value=(rows, None), + ), + ): + result = runner.invoke(app, ["auth", "status"]) + + assert result.exit_code == 0 + assert "Grant rows: users:write, clients:write" in result.stdout + + def test_unreadable_grant_rows_explain_why(self): + """A 403 from the grants list degrades to an explanatory note.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_VALID, None), + ), + mock.patch( + "campus_cli.auth.status.fetch_grant_rows", + return_value=(None, "grant rows are visible to the operator"), + ), + ): + result = runner.invoke(app, ["auth", "status"]) + + assert result.exit_code == 0 + assert "Grant rows:" in result.stdout + assert "visible to the operator" in result.stdout + + def test_offline_notes_grant_rows_not_fetched(self): + """--offline says grant rows were not fetched.""" + creds = _mock_credentials() + with mock.patch("campus_cli.auth.common.credentials", creds): + result = runner.invoke(app, ["auth", "status", "--offline", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["grants"] is None + assert "offline" in payload["grants_note"] + def test_unreachable_server_is_nonfatal(self): """Network failure warns but exits 0: identity still printed.""" creds = _mock_credentials()