--read/--write options plus --token-only, closes #34

This commit is contained in:
Simon Willison 2026-02-26 21:45:58 -08:00
commit 54df24db5e
3 changed files with 342 additions and 3 deletions

View file

@ -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

View file

@ -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
<!-- [[[cog
@ -58,9 +96,19 @@ Usage: dclient login [OPTIONS] [ALIAS_OR_URL]
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
Options:
--scope TEXT JSON scope array
--read-all Request instance-wide read access
--write-all Request instance-wide write access
--read TEXT Request read access for a database or database/table
--write TEXT Request write access for a database or database/table
--token-only Output the token to stdout instead of saving it
--help Show this message and exit.
```

View file

@ -290,6 +290,226 @@ def test_login_with_alias_sets_defaults(httpx_mock, mocker, tmpdir):
assert config["instances"]["prod"]["default_database"] == "data"
def test_login_read_all(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--read-all"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-table"],
["view-database"],
["view-query"],
["execute-sql"],
]
def test_login_write_all(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--write-all"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-table"],
["view-database"],
["view-query"],
["execute-sql"],
["insert-row"],
["delete-row"],
["update-row"],
["create-table"],
["alter-table"],
["drop-table"],
]
def test_login_read_database(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--read", "db1"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db1"],
["view-table", "db1"],
["view-database", "db1"],
["view-query", "db1"],
["execute-sql", "db1"],
]
def test_login_write_table(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["login", "https://example.com/", "--write", "db3/submissions"]
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db3", "submissions"],
["view-table", "db3", "submissions"],
["view-database", "db3", "submissions"],
["view-query", "db3", "submissions"],
["execute-sql", "db3", "submissions"],
["insert-row", "db3", "submissions"],
["delete-row", "db3", "submissions"],
["update-row", "db3", "submissions"],
["create-table", "db3", "submissions"],
["alter-table", "db3", "submissions"],
["drop-table", "db3", "submissions"],
]
def test_login_mixed_read_write(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
["login", "https://example.com/", "--read", "db1", "--write", "db3/dogs"],
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db1"],
["view-table", "db1"],
["view-database", "db1"],
["view-query", "db1"],
["execute-sql", "db1"],
["view-instance", "db3", "dogs"],
["view-table", "db3", "dogs"],
["view-database", "db3", "dogs"],
["view-query", "db3", "dogs"],
["execute-sql", "db3", "dogs"],
["insert-row", "db3", "dogs"],
["delete-row", "db3", "dogs"],
["update-row", "db3", "dogs"],
["create-table", "db3", "dogs"],
["alter-table", "db3", "dogs"],
["drop-table", "db3", "dogs"],
]
def test_login_scope_combined_with_shortcuts(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
[
"login",
"https://example.com/",
"--scope",
'[["view-instance"]]',
"--write",
"db1/dogs",
],
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-instance", "db1", "dogs"],
["view-table", "db1", "dogs"],
["view-database", "db1", "dogs"],
["view-query", "db1", "dogs"],
["execute-sql", "db1", "dogs"],
["insert-row", "db1", "dogs"],
["delete-row", "db1", "dogs"],
["update-row", "db1", "dogs"],
["create-table", "db1", "dogs"],
["alter-table", "db1", "dogs"],
["drop-table", "db1", "dogs"],
]
def test_login_no_scope_sends_no_scope(httpx_mock, mocker, tmpdir):
"""Without any scope options, no scope field should be sent."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0, result.output
request = httpx_mock.get_requests()[0]
assert request.content == b""
def test_login_token_only(httpx_mock, mocker, tmpdir):
"""--token-only prints the token to stdout and does not save it."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["login", "https://example.com/", "--token-only", "--read", "foo/bar"]
)
assert result.exit_code == 0, result.output
# Last line of output should be the raw token
assert result.output.strip().endswith("dstok_abc123")
# Should NOT have "Login successful" message
assert "Login successful" not in result.output
# auth.json should not exist
auth_file = pathlib.Path(tmpdir) / "auth.json"
assert not auth_file.exists()
# config.json should not exist (no defaults set)
config_file = pathlib.Path(tmpdir) / "config.json"
assert not config_file.exists()
def test_login_databases_error_still_succeeds(httpx_mock, mocker, tmpdir):
"""If the databases check fails, login should still succeed."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))