feat(auth): CLI users subcommand
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""CLI subcommand: ``python -m cyclone users ...``.
|
||||
|
||||
Click-based to match the existing parse-837 / parse-835 convention in
|
||||
``cyclone.cli``. Provides operator-side user management without going
|
||||
through the admin HTTP API.
|
||||
|
||||
Subcommands
|
||||
-----------
|
||||
|
||||
- ``users create USERNAME --role {admin,user,viewer} [--password PW]``
|
||||
Create a user. If ``--password`` is omitted, prompts (with
|
||||
confirmation) on the controlling terminal.
|
||||
- ``users list`` — tab-separated ``id / username / role / state``.
|
||||
- ``users disable USERNAME`` — set ``disabled_at`` to now.
|
||||
- ``users reset-password USERNAME [--password PW]`` — replace the hash.
|
||||
- ``users set-role USERNAME --role {admin,user,viewer}`` — change role.
|
||||
|
||||
Exit codes
|
||||
----------
|
||||
|
||||
* 0 — success
|
||||
* 1 — validation error (unknown username, duplicate username)
|
||||
* 2 — usage error (missing arg, bad role, short password, unknown
|
||||
subcommand). Click itself uses 2 for usage errors so the conventional
|
||||
shell tools (``set -e``, etc.) recognize them.
|
||||
|
||||
Passwords shorter than 12 chars are rejected everywhere they appear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from cyclone.auth import users
|
||||
from cyclone.auth.permissions import Role
|
||||
from cyclone.db import SessionLocal
|
||||
|
||||
ROLE_CHOICES = [Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]
|
||||
MIN_PASSWORD_LEN = 12
|
||||
|
||||
|
||||
def _prompt_password(label: str) -> str:
|
||||
pw = getpass.getpass(f"{label}: ")
|
||||
if not pw:
|
||||
click.echo("Password required.", err=True)
|
||||
sys.exit(2)
|
||||
return pw
|
||||
|
||||
|
||||
def _validate_password(pw: str) -> None:
|
||||
if len(pw) < MIN_PASSWORD_LEN:
|
||||
click.echo(
|
||||
f"Password must be at least {MIN_PASSWORD_LEN} characters.",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Group
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@click.group(name="users")
|
||||
def users_cli() -> None:
|
||||
"""Manage Cyclone users from the command line."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# create
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@users_cli.command("create")
|
||||
@click.argument("username")
|
||||
@click.option(
|
||||
"--role",
|
||||
required=True,
|
||||
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
|
||||
help="Role to grant the new user.",
|
||||
)
|
||||
@click.option(
|
||||
"--password",
|
||||
default=None,
|
||||
help=f"Password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
|
||||
)
|
||||
def create_user(username: str, role: str, password: str | None) -> None:
|
||||
"""Create a new user with the given USERNAME and ROLE."""
|
||||
pw = password or _prompt_password("Password")
|
||||
_validate_password(pw)
|
||||
|
||||
with SessionLocal()() as db:
|
||||
if users.get_by_username(db, username) is not None:
|
||||
click.echo(f"User '{username}' already exists.", err=True)
|
||||
sys.exit(1)
|
||||
u = users.create(db, username=username, password=pw, role=role)
|
||||
click.echo(f"Created user '{u.username}' with role '{u.role}'.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# list
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@users_cli.command("list")
|
||||
def list_users() -> None:
|
||||
"""List all users as id / username / role / state."""
|
||||
from cyclone.db import User
|
||||
|
||||
with SessionLocal()() as db:
|
||||
for u in db.query(User).order_by(User.id.asc()).all():
|
||||
state = "disabled" if u.disabled_at else "active"
|
||||
click.echo(f"{u.id}\t{u.username}\t{u.role}\t{state}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# disable
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@users_cli.command("disable")
|
||||
@click.argument("username")
|
||||
def disable_user(username: str) -> None:
|
||||
"""Disable USERNAME (sets disabled_at to now)."""
|
||||
with SessionLocal()() as db:
|
||||
u = users.get_by_username(db, username)
|
||||
if u is None:
|
||||
click.echo(f"No such user: {username}", err=True)
|
||||
sys.exit(1)
|
||||
users.disable(db, u.id)
|
||||
click.echo(f"Disabled '{username}'.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# reset-password
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@users_cli.command("reset-password")
|
||||
@click.argument("username")
|
||||
@click.option(
|
||||
"--password",
|
||||
default=None,
|
||||
help=f"New password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
|
||||
)
|
||||
def reset_password(username: str, password: str | None) -> None:
|
||||
"""Replace USERNAME's password."""
|
||||
pw = password or _prompt_password("New password")
|
||||
_validate_password(pw)
|
||||
with SessionLocal()() as db:
|
||||
u = users.get_by_username(db, username)
|
||||
if u is None:
|
||||
click.echo(f"No such user: {username}", err=True)
|
||||
sys.exit(1)
|
||||
users.update_password(db, u.id, pw)
|
||||
click.echo(f"Password reset for '{username}'.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# set-role
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@users_cli.command("set-role")
|
||||
@click.argument("username")
|
||||
@click.option(
|
||||
"--role",
|
||||
required=True,
|
||||
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
|
||||
help="Role to grant.",
|
||||
)
|
||||
def set_role(username: str, role: str) -> None:
|
||||
"""Change USERNAME's role."""
|
||||
with SessionLocal()() as db:
|
||||
u = users.get_by_username(db, username)
|
||||
if u is None:
|
||||
click.echo(f"No such user: {username}", err=True)
|
||||
sys.exit(1)
|
||||
users.update_role(db, u.id, role)
|
||||
click.echo(f"Role for '{username}' set to '{role}'.")
|
||||
@@ -74,6 +74,12 @@ def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> N
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
|
||||
# Register the auth users subgroup. Imported here (not at module top) to
|
||||
# avoid pulling passlib / bcrypt at CLI parse-only import time.
|
||||
from cyclone.auth.cli import users_cli # noqa: E402
|
||||
main.add_command(users_cli)
|
||||
|
||||
|
||||
@main.command("parse-837")
|
||||
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""CLI: python -m cyclone users {create,list,disable,reset-password,set-role}.
|
||||
|
||||
Uses Click's CliRunner (matches the existing parse-837/parse-835 CLI tests).
|
||||
The full CLI group lives in ``cyclone.cli.main`` — we exercise the
|
||||
``users`` subgroup through the top-level ``main`` so the wiring is real.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from cyclone.auth import users
|
||||
from cyclone.cli import main as cli_main
|
||||
from cyclone.db import Session as DbSession
|
||||
from cyclone.db import SessionLocal, User
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear():
|
||||
with SessionLocal()() as db:
|
||||
db.execute(delete(DbSession))
|
||||
db.execute(delete(User))
|
||||
db.commit()
|
||||
yield
|
||||
with SessionLocal()() as db:
|
||||
db.execute(delete(DbSession))
|
||||
db.execute(delete(User))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_create_user_via_cli():
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli_main,
|
||||
[
|
||||
"users", "create", "cli-user",
|
||||
"--role", "viewer",
|
||||
"--password", "clipassword1",
|
||||
],
|
||||
input="", # don't prompt
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
with SessionLocal()() as db:
|
||||
u = users.get_by_username(db, "cli-user")
|
||||
assert u is not None
|
||||
assert u.role == "viewer"
|
||||
|
||||
|
||||
def test_list_users_via_cli():
|
||||
from click.testing import CliRunner
|
||||
with SessionLocal()() as db:
|
||||
users.create(db, username="listed", password="hunter2hunter2", role="user")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli_main, ["users", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "listed" in result.output
|
||||
|
||||
|
||||
def test_disable_user_via_cli():
|
||||
from click.testing import CliRunner
|
||||
with SessionLocal()() as db:
|
||||
users.create(db, username="todie", password="hunter2hunter2", role="user")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli_main, ["users", "disable", "todie"])
|
||||
assert result.exit_code == 0, result.output
|
||||
with SessionLocal()() as db:
|
||||
u = users.get_by_username(db, "todie")
|
||||
assert u.disabled_at is not None
|
||||
|
||||
|
||||
def test_reset_password_via_cli():
|
||||
from click.testing import CliRunner
|
||||
with SessionLocal()() as db:
|
||||
users.create(db, username="pwchange", password="oldpassword1", role="user")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli_main,
|
||||
[
|
||||
"users", "reset-password", "pwchange",
|
||||
"--password", "newpassword1",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
with SessionLocal()() as db:
|
||||
u = users.get_by_username(db, "pwchange")
|
||||
assert users.verify_password("newpassword1", u.password_hash)
|
||||
|
||||
|
||||
def test_set_role_via_cli():
|
||||
from click.testing import CliRunner
|
||||
with SessionLocal()() as db:
|
||||
users.create(db, username="promote", password="hunter2hunter2", role="viewer")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli_main,
|
||||
["users", "set-role", "promote", "--role", "admin"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
with SessionLocal()() as db:
|
||||
u = users.get_by_username(db, "promote")
|
||||
assert u.role == "admin"
|
||||
|
||||
|
||||
def test_create_rejects_short_password():
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli_main,
|
||||
[
|
||||
"users", "create", "weak",
|
||||
"--role", "viewer",
|
||||
"--password", "short",
|
||||
],
|
||||
)
|
||||
# Click surfaces validation failures with a non-zero exit code.
|
||||
assert result.exit_code != 0, result.output
|
||||
with SessionLocal()() as db:
|
||||
rows = db.execute(select(User).where(User.username == "weak")).scalars().all()
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_disable_unknown_user_exits_nonzero():
|
||||
from click.testing import CliRunner
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli_main, ["users", "disable", "ghost"])
|
||||
assert result.exit_code != 0, result.output
|
||||
Reference in New Issue
Block a user