From d99512a359410a8f6c3f421dd8c5319524b888e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Wed, 4 Nov 2026 23:27:25 +0100 Subject: [PATCH 1/3] Add product and reseller scopes --- CHANGELOG.md | 12 +++++++++ README.md | 21 ++++++++++++++++ README.stub | 21 ++++++++++++++++ src/Id/Enums/Scope.php | 11 +++++++++ tests/Id/ScopeTest.php | 55 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 tests/Id/ScopeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..72acdbd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 3.1.0 (unreleased) + +- Add the scopes `products` and `products:read` (`Scope::PRODUCTS`, `Scope::PRODUCTS_READ`) for the + product APIs of the Anikeen Console. +- Add the reseller scopes `reseller-customers:read`, `reseller-customers`, `reseller-grants` and + `reseller-orders` (`Scope::RESELLER_CUSTOMERS_READ`, `Scope::RESELLER_CUSTOMERS`, + `Scope::RESELLER_GRANTS`, `Scope::RESELLER_ORDERS`) for the reseller API of the Anikeen Console. +- Document the scope schema and the new scopes in the README. + +Older releases: see the [GitHub releases](https://github.com/anikeen-com/id/releases) and tags. diff --git a/README.md b/README.md index 4e7866e..70b55b5 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,27 @@ if ($result->success()) { } ``` +### Scopes + +Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and +`resource:client` access to what the app or token created itself. All of them are constants on +`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`. + +The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with +these scopes for its product and reseller APIs: + +| Scope | Constant | Grants | +|-------|----------|--------| +| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams | +| `products` | `Scope::PRODUCTS` | full access to the products of your teams | +| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations | +| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) | +| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it | +| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away | + +A full scope includes reading. `user` and `*` do not open the reseller endpoints: the token has to +carry the reseller scope itself. + ### Change the default access token / refresh token field name If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: diff --git a/README.stub b/README.stub index a7aa976..3b8b76a 100644 --- a/README.stub +++ b/README.stub @@ -160,6 +160,27 @@ if ($result->success()) { } ``` +### Scopes + +Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and +`resource:client` access to what the app or token created itself. All of them are constants on +`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`. + +The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with +these scopes for its product and reseller APIs: + +| Scope | Constant | Grants | +|-------|----------|--------| +| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams | +| `products` | `Scope::PRODUCTS` | full access to the products of your teams | +| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations | +| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) | +| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it | +| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away | + +A full scope includes reading. `user` and `*` do not open the reseller endpoints: the token has to +carry the reseller scope itself. + ### Change the default access token / refresh token field name If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: diff --git a/src/Id/Enums/Scope.php b/src/Id/Enums/Scope.php index 4e8af15..3b77fac 100644 --- a/src/Id/Enums/Scope.php +++ b/src/Id/Enums/Scope.php @@ -36,5 +36,16 @@ class Scope const SSH_KEYS = 'ssh-keys'; const SSH_KEYS_READ = 'ssh-keys:read'; + // Products of the Anikeen Console (servers, floating IPs, hosting, domains, zones, object storage). + const PRODUCTS = 'products'; + const PRODUCTS_READ = 'products:read'; + + // Reseller API of the Anikeen Console. A full scope includes reading; `user` and `*` do not + // open these endpoints, a token must carry the reseller scope itself. + const RESELLER_CUSTOMERS = 'reseller-customers'; + const RESELLER_CUSTOMERS_READ = 'reseller-customers:read'; + const RESELLER_GRANTS = 'reseller-grants'; + const RESELLER_ORDERS = 'reseller-orders'; + const ADMIN = 'admin'; } diff --git a/tests/Id/ScopeTest.php b/tests/Id/ScopeTest.php new file mode 100644 index 0000000..59c41a9 --- /dev/null +++ b/tests/Id/ScopeTest.php @@ -0,0 +1,55 @@ + [Scope::PRODUCTS, 'products'], + 'products:read' => [Scope::PRODUCTS_READ, 'products:read'], + 'reseller-customers' => [Scope::RESELLER_CUSTOMERS, 'reseller-customers'], + 'reseller-customers:read' => [Scope::RESELLER_CUSTOMERS_READ, 'reseller-customers:read'], + 'reseller-grants' => [Scope::RESELLER_GRANTS, 'reseller-grants'], + 'reseller-orders' => [Scope::RESELLER_ORDERS, 'reseller-orders'], + ]; + } + + #[DataProvider('newScopes')] + public function test_the_product_and_reseller_scopes_have_their_names(string $constant, string $expected): void + { + $this->assertSame($expected, $constant); + } + + public function test_every_scope_follows_the_resource_and_level_schema(): void + { + $scopes = array_values((new ReflectionClass(Scope::class))->getConstants()); + + $this->assertSame($scopes, array_values(array_unique($scopes)), 'scope values must be unique'); + + foreach ($scopes as $scope) { + // "resource" (full access), "resource:read" or "resource:client"; Anikeen ID groups by resource + $this->assertMatchesRegularExpression('/^[a-z]+(-[a-z]+)*(:(read|client))?$/', $scope); + } + } + + public function test_each_reseller_resource_is_its_own_group(): void + { + $groups = collect((new ReflectionClass(Scope::class))->getConstants()) + ->filter(fn (string $scope) => str_starts_with($scope, 'reseller-')) + ->groupBy(fn (string $scope) => explode(':', $scope)[0]) + ->map(fn ($scopes) => $scopes->sort()->values()->all()) + ->all(); + + $this->assertSame([ + 'reseller-customers' => ['reseller-customers', 'reseller-customers:read'], + 'reseller-grants' => ['reseller-grants'], + 'reseller-orders' => ['reseller-orders'], + ], $groups); + } +} From e8432df997651fed9699b3778e5433405eb0cca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Thu, 5 Nov 2026 00:11:35 +0100 Subject: [PATCH 2/3] Drop the full products scope the Console does not accept The Console's product APIs accept user, user:read and products:read only (decision 21 names products:read). A token with just "products" was refused everywhere despite its consent text. --- CHANGELOG.md | 4 ++-- README.md | 6 +++--- README.stub | 6 +++--- src/Id/Enums/Scope.php | 4 ++-- tests/Id/ScopeTest.php | 7 ++++++- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72acdbd..8293c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## 3.1.0 (unreleased) -- Add the scopes `products` and `products:read` (`Scope::PRODUCTS`, `Scope::PRODUCTS_READ`) for the - product APIs of the Anikeen Console. +- Add the scope `products:read` (`Scope::PRODUCTS_READ`) for read-only access to the product APIs + of the Anikeen Console. - Add the reseller scopes `reseller-customers:read`, `reseller-customers`, `reseller-grants` and `reseller-orders` (`Scope::RESELLER_CUSTOMERS_READ`, `Scope::RESELLER_CUSTOMERS`, `Scope::RESELLER_GRANTS`, `Scope::RESELLER_ORDERS`) for the reseller API of the Anikeen Console. diff --git a/README.md b/README.md index 70b55b5..1da3dae 100644 --- a/README.md +++ b/README.md @@ -172,14 +172,14 @@ these scopes for its product and reseller APIs: | Scope | Constant | Grants | |-------|----------|--------| | `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams | -| `products` | `Scope::PRODUCTS` | full access to the products of your teams | | `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations | | `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) | | `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it | | `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away | -A full scope includes reading. `user` and `*` do not open the reseller endpoints: the token has to -carry the reseller scope itself. +A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the +token has to carry the reseller scope itself. For the product APIs there is only `products:read`; +changing products needs `user`. ### Change the default access token / refresh token field name diff --git a/README.stub b/README.stub index 3b8b76a..6fb0a71 100644 --- a/README.stub +++ b/README.stub @@ -172,14 +172,14 @@ these scopes for its product and reseller APIs: | Scope | Constant | Grants | |-------|----------|--------| | `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams | -| `products` | `Scope::PRODUCTS` | full access to the products of your teams | | `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations | | `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) | | `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it | | `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away | -A full scope includes reading. `user` and `*` do not open the reseller endpoints: the token has to -carry the reseller scope itself. +A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the +token has to carry the reseller scope itself. For the product APIs there is only `products:read`; +changing products needs `user`. ### Change the default access token / refresh token field name diff --git a/src/Id/Enums/Scope.php b/src/Id/Enums/Scope.php index 3b77fac..0ce17a2 100644 --- a/src/Id/Enums/Scope.php +++ b/src/Id/Enums/Scope.php @@ -36,8 +36,8 @@ class Scope const SSH_KEYS = 'ssh-keys'; const SSH_KEYS_READ = 'ssh-keys:read'; - // Products of the Anikeen Console (servers, floating IPs, hosting, domains, zones, object storage). - const PRODUCTS = 'products'; + // Products of the Anikeen Console (servers, floating IPs, hosting, domains, zones, object storage), + // read-only. Changing products needs `user`; there is no full `products` scope. const PRODUCTS_READ = 'products:read'; // Reseller API of the Anikeen Console. A full scope includes reading; `user` and `*` do not diff --git a/tests/Id/ScopeTest.php b/tests/Id/ScopeTest.php index 59c41a9..08b3642 100644 --- a/tests/Id/ScopeTest.php +++ b/tests/Id/ScopeTest.php @@ -11,7 +11,6 @@ class ScopeTest extends TestCase public static function newScopes(): array { return [ - 'products' => [Scope::PRODUCTS, 'products'], 'products:read' => [Scope::PRODUCTS_READ, 'products:read'], 'reseller-customers' => [Scope::RESELLER_CUSTOMERS, 'reseller-customers'], 'reseller-customers:read' => [Scope::RESELLER_CUSTOMERS_READ, 'reseller-customers:read'], @@ -26,6 +25,12 @@ class ScopeTest extends TestCase $this->assertSame($expected, $constant); } + public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void + { + // the Console's product APIs accept `user`, `user:read` and `products:read` only + $this->assertNotContains('products', (new ReflectionClass(Scope::class))->getConstants()); + } + public function test_every_scope_follows_the_resource_and_level_schema(): void { $scopes = array_values((new ReflectionClass(Scope::class))->getConstants()); From 72f761b0771128ccbec8a7a6e25e736126e1729f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Thu, 5 Nov 2026 00:21:52 +0100 Subject: [PATCH 3/3] Release 3.1.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8293c38..6bd3a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 3.1.0 (unreleased) +## 3.1.0 (2026-11-05) - Add the scope `products:read` (`Scope::PRODUCTS_READ`) for read-only access to the product APIs of the Anikeen Console.