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); + } +}