diff --git a/README.md b/README.md index 485fcd5..ac9394b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ chi-edge device register \ After this stage, the device will appear with (`2/4` checks) passing. +> **Note:** newly registered devices are restricted to your current Chameleon project by default — only reservations from that project can use them. See [Access control](#access-control) to broaden or review access. + ### 2. Bake the OS image Download the appropriate [balenaOS image](https://chameleoncloud.gitbook.io/chi-edge/edge-sdk#download-balena-os-image) for your device, then configure it for the testbed: @@ -54,11 +56,42 @@ Write the baked image to your device's storage (microSD or eMMC) using [balenaEt | Command | Description | |---------|-------------| | `chi-edge device list` | List your registered devices | +| `chi-edge device list --long` | Include type, project restrictions, contact, and local egress columns | | `chi-edge device show ` | Show device details and health | | `chi-edge device set` | Update device configuration | | `chi-edge device delete ` | Remove a device | | `chi-edge device sync ` | Force device re-sync | +## Access control + +Every device has an `authorized_projects` list that controls which Chameleon projects can reserve it. A fresh registration sets this to your current project only. + +### See the current state + +``` +chi-edge device list --long +``` + +The `Restricted to` column shows the authorized projects, or `public` if the device has no restrictions. + +### Add or change authorized projects + +``` +chi-edge device set --authorized-projects proj-a,proj-b,proj-c +``` + +The list is replaced wholesale — include every project that should retain access. + +### Make a device public + +Before removing project restrictions, reserve and use the device from your own project to confirm it's working as expected. Once you're satisfied: + +``` +chi-edge device set --unset authorized_projects +``` + +The device will now accept reservations from any Chameleon project. + ## Configuration Uses OpenStack [clouds.yaml](https://docs.openstack.org/python-openstackclient/latest/configuration/index.html) or environment variables for authentication. Specify the cloud with `--os-cloud` or set `OS_CLOUD`. diff --git a/chi_edge/cli.py b/chi_edge/cli.py index e2a6a0a..6ced40b 100644 --- a/chi_edge/cli.py +++ b/chi_edge/cli.py @@ -157,6 +157,11 @@ def register( ctx = click.get_current_context() conn = openstack.connect(cloud=ctx.obj.get("os_cloud")) + if not conn.current_project_id: + raise click.ClickException( + "cannot register device: no project scope on current auth session" + ) + if bool(application_credential_id) != bool(application_credential_secret): raise click.ClickException( "--application-credential-id and --application-credential-secret must be provided together" @@ -194,6 +199,7 @@ def register( "application_credential_secret": application_credential_secret, "contact_email": contact_email, "machine_name": machine_name, + "authorized_projects": [conn.current_project_id], }, }, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5b8c617..23f9588 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -351,3 +351,79 @@ def test_device_set_rejects_set_and_unset_of_same_property(): assert result.exit_code != 0 assert "Cannot both set and unset --contact-email" in result.output mock_adapter.patch.assert_not_called() + + +def _register_mocks(project_id: "str | None" = "proj-abc"): + """Return (mock_conn, mock_adapter) wired for a successful register.""" + mock_conn = MagicMock() + mock_conn.current_project_id = project_id + mock_conn.current_user_id = "user-xyz" + mock_conn.identity.application_credentials.return_value = [] + mock_conn.identity.create_application_credential.return_value = MagicMock( + id="cred-id", secret="cred-secret" + ) + mock_adapter = MagicMock() + mock_adapter.post.return_value.json.return_value = FAKE_DEVICE + return mock_conn, mock_adapter + + +def test_register_auto_authorizes_owning_project(): + mock_conn, mock_adapter = _register_mocks(project_id="proj-abc") + + runner = CliRunner() + with ( + patch("chi_edge.cli.openstack") as mock_os, + patch("chi_edge.cli.doni_client", return_value=mock_adapter), + patch("chi_edge.cli.utils.validate_rfc1123_name", return_value=True), + ): + mock_os.connect.return_value = mock_conn + result = runner.invoke( + cli, + [ + "device", + "register", + "iot-rpi4-01", + "--machine-name", + "raspberrypi4-64", + "--contact-email", + "test@example.com", + "--application-credential-id", + "cid", + "--application-credential-secret", + "csec", + ], + ) + assert result.exit_code == 0, result.output + ((_, kwargs),) = mock_adapter.post.call_args_list + assert kwargs["json"]["properties"]["authorized_projects"] == ["proj-abc"] + + +def test_register_fails_without_project_scope(): + mock_conn, mock_adapter = _register_mocks(project_id=None) + + runner = CliRunner() + with ( + patch("chi_edge.cli.openstack") as mock_os, + patch("chi_edge.cli.doni_client", return_value=mock_adapter), + patch("chi_edge.cli.utils.validate_rfc1123_name", return_value=True), + ): + mock_os.connect.return_value = mock_conn + result = runner.invoke( + cli, + [ + "device", + "register", + "iot-rpi4-01", + "--machine-name", + "raspberrypi4-64", + "--contact-email", + "test@example.com", + "--application-credential-id", + "cid", + "--application-credential-secret", + "csec", + ], + ) + assert result.exit_code != 0 + assert "no project scope" in result.output + mock_adapter.post.assert_not_called()