From 54df24db5e167206fcf8528370ece375df7b9675 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 21:45:58 -0800 Subject: [PATCH] --read/--write options plus --token-only, closes #34 --- dclient/cli.py | 75 +++++++++++++- docs/authentication.md | 50 +++++++++- tests/test_cli_auth.py | 220 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 3 deletions(-) diff --git a/dclient/cli.py b/dclient/cli.py index 8b7dc72..05dc98d 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -1450,10 +1450,72 @@ def auth_status(instance, token): # -- login command (OAuth device flow) -- +READ_SCOPES = [ + "view-instance", + "view-table", + "view-database", + "view-query", + "execute-sql", +] +WRITE_SCOPES = [ + "insert-row", + "delete-row", + "update-row", + "create-table", + "alter-table", + "drop-table", +] + + +def _merge_scopes(scope, read_all, write_all, read, write): + """Merge --scope JSON with --read-all/--write-all/--read/--write options.""" + scopes = json.loads(scope) if scope else [] + if read_all: + for action in READ_SCOPES: + scopes.append([action]) + if write_all: + for action in READ_SCOPES + WRITE_SCOPES: + scopes.append([action]) + for target in read or []: + parts = target.split("/", 1) + if len(parts) == 1: + for action in READ_SCOPES: + scopes.append([action, parts[0]]) + else: + for action in READ_SCOPES: + scopes.append([action, parts[0], parts[1]]) + for target in write or []: + parts = target.split("/", 1) + if len(parts) == 1: + for action in READ_SCOPES + WRITE_SCOPES: + scopes.append([action, parts[0]]) + else: + for action in READ_SCOPES + WRITE_SCOPES: + scopes.append([action, parts[0], parts[1]]) + if scopes: + return json.dumps(scopes) + return None + + @cli.command() @click.argument("alias_or_url", required=False, default=None) @click.option("--scope", default=None, help="JSON scope array") -def login(alias_or_url, scope): +@click.option("--read-all", is_flag=True, help="Request instance-wide read access") +@click.option("--write-all", is_flag=True, help="Request instance-wide write access") +@click.option( + "--read", multiple=True, help="Request read access for a database or database/table" +) +@click.option( + "--write", + multiple=True, + help="Request write access for a database or database/table", +) +@click.option( + "--token-only", + is_flag=True, + help="Output the token to stdout instead of saving it", +) +def login(alias_or_url, scope, read_all, write_all, read, write, token_only): """ Authenticate with a Datasette instance using OAuth @@ -1466,7 +1528,13 @@ def login(alias_or_url, scope): dclient login https://simon.datasette.cloud/ dclient login myalias dclient login + dclient login --read-all + dclient login --write-all + dclient login --read db1 + dclient login --write db3/submissions + dclient login --read db1 --write db3/dogs """ + scope = _merge_scopes(scope, read_all, write_all, read, write) config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "config.json" @@ -1539,9 +1607,12 @@ def login(alias_or_url, scope): click.echo() raise click.ClickException(f"Unexpected error: {error}") - # Step 4: Save token + # Step 4: Save token (or print it) click.echo() access_token = token_data["access_token"] + if token_only: + click.echo(access_token) + return auth_file = config_dir / "auth.json" auths = _load_auths(auth_file) auths[auth_key] = access_token diff --git a/docs/authentication.md b/docs/authentication.md index d83042a..40a7700 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -26,12 +26,50 @@ dclient login This will display a URL and a code. Open the URL in your browser, enter the code to approve access, and the resulting API token will be saved automatically. If you run `dclient login` without an argument, you will be prompted for the instance URL or alias. -You can also pass a `--scope` option to request specific permissions: +### Requesting scoped tokens + +By default, `dclient login` requests an unrestricted token. You can request a token with limited permissions using the shorthand options: + +```bash +# Instance-wide read or write access +dclient login --read-all +dclient login --write-all + +# Database-level access +dclient login --read db1 +dclient login --write db3 + +# Table-level access +dclient login --read db1/dogs +dclient login --write db3/submissions + +# Mixed +dclient login --read db1 --write db3/dogs +``` + +`--read-all` grants: `view-instance`, `view-table`, `view-database`, `view-query`, `execute-sql`. + +`--write-all` grants all read scopes plus: `insert-row`, `delete-row`, `update-row`, `create-table`, `alter-table`, `drop-table`. + +`--read` and `--write` accept a database name or `database/table` and can be specified multiple times. `--write` implies read access for the same target. + +For advanced use, you can also pass raw scope JSON with `--scope`: ```bash dclient login myalias --scope '[["view-instance"]]' ``` +All scope options can be combined — the shorthand options append to whatever `--scope` provides. + +### Outputting the token directly + +Use `--token-only` to print the token to stdout instead of saving it. This is useful for creating debug tokens or piping them into other tools: + +```bash +dclient login --token-only --read mydb +dclient login --token-only --write-all +``` + ## dclient login --help