From 642e518259b66fb28697020194b0c022e6c45f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 15:24:48 +0000 Subject: [PATCH 1/2] Give trusted apps the user's role Anikeen ID now sends trusted apps the user's role (admin or user) in GET v1/user, a user read by ID and user.updated, so an app like the Console can take its admins from Anikeen ID. Enums\Role, the Socialite user's role and Resources\User::role()/isAdmin() expose it. --- CHANGELOG.md | 11 +++++++ README.md | 22 ++++++++++++++ README.stub | 22 ++++++++++++++ src/Id/Enums/Role.php | 22 ++++++++++++++ src/Id/Resources/User.php | 18 ++++++++++++ src/Id/Socialite/Provider.php | 2 ++ src/Id/Webhooks/EventType.php | 3 +- tests/Id/UserRoleTest.php | 55 +++++++++++++++++++++++++++++++++++ 8 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 src/Id/Enums/Role.php create mode 100644 tests/Id/UserRoleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b769d..93f8795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +The user's role for trusted apps; it needs an Anikeen ID release that sends it. + +### Added + +- `Enums\Role` (`ADMIN`, `USER`): the user's Anikeen ID role, seen by trusted apps only. +- The Socialite user carries it as `$user->role` (`null` for other apps). +- `Resources\User::role()` and `isAdmin()`, for a user read by ID (`findUserById()`) and the + `data.object` of `user.updated`, which for trusted apps carries `role` (and `role` in `changed`). + ## 3.4.0 (2026-11-07) Preferences, charges, the billing limit, user profiles and the user and preference webhooks; they diff --git a/README.md b/README.md index 8046300..4706a14 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,22 @@ Route::get('auth/callback', function () { }); ``` +### The user's role (trusted apps) + +[Trusted apps](#trusted-apps) also get the user's Anikeen ID role, `admin` (runs Anikeen) or +`user` (constants on `Anikeen\Id\Enums\Role`); for other apps it is `null`. An app can make +Anikeen ID admins its own admins this way instead of keeping a list of its own. Read it at sign-in +and keep it current with the `user.updated` webhook (`role` in `changed`, see +[User profiles](#user-profiles)): + +```php +use Anikeen\Id\Enums\Role; + +$account = Socialite::driver('anikeen')->user(); + +$user->update(['role' => $account->role ?? Role::USER]); +``` + Access tokens expire. Get a new one with the refresh token (or send the user through the login again): @@ -1207,8 +1223,14 @@ $profile?->emailVerified(); $profile?->avatar(); $profile?->version(); // compare with the version of user.updated $profile?->locale; // also username, created_at +$profile?->role(); // Role::ADMIN or Role::USER (trusted apps only) +$profile?->isAdmin(); ``` +The `data.object` of `user.updated` is the same profile (`new \Anikeen\Id\Resources\User($event['data']['object'])`); +for trusted apps it carries `role`, and a change of the role alone (`changed` is `["role"]`) is +sent to them only. + It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429 `too_many_requests` and other failures. The profile carries no billing or account data; read those through their own endpoints. diff --git a/README.stub b/README.stub index 16dd144..af58804 100644 --- a/README.stub +++ b/README.stub @@ -148,6 +148,22 @@ Route::get('auth/callback', function () { }); ``` +### The user's role (trusted apps) + +[Trusted apps](#trusted-apps) also get the user's Anikeen ID role, `admin` (runs Anikeen) or +`user` (constants on `Anikeen\Id\Enums\Role`); for other apps it is `null`. An app can make +Anikeen ID admins its own admins this way instead of keeping a list of its own. Read it at sign-in +and keep it current with the `user.updated` webhook (`role` in `changed`, see +[User profiles](#user-profiles)): + +```php +use Anikeen\Id\Enums\Role; + +$account = Socialite::driver('anikeen')->user(); + +$user->update(['role' => $account->role ?? Role::USER]); +``` + Access tokens expire. Get a new one with the refresh token (or send the user through the login again): @@ -1207,8 +1223,14 @@ $profile?->emailVerified(); $profile?->avatar(); $profile?->version(); // compare with the version of user.updated $profile?->locale; // also username, created_at +$profile?->role(); // Role::ADMIN or Role::USER (trusted apps only) +$profile?->isAdmin(); ``` +The `data.object` of `user.updated` is the same profile (`new \Anikeen\Id\Resources\User($event['data']['object'])`); +for trusted apps it carries `role`, and a change of the role alone (`changed` is `["role"]`) is +sent to them only. + It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429 `too_many_requests` and other failures. The profile carries no billing or account data; read those through their own endpoints. diff --git a/src/Id/Enums/Role.php b/src/Id/Enums/Role.php new file mode 100644 index 0000000..f700623 --- /dev/null +++ b/src/Id/Enums/Role.php @@ -0,0 +1,22 @@ + + */ + public static function all(): array + { + return [self::ADMIN, self::USER]; + } +} diff --git a/src/Id/Resources/User.php b/src/Id/Resources/User.php index c92e2fb..bbb2ea0 100644 --- a/src/Id/Resources/User.php +++ b/src/Id/Resources/User.php @@ -2,6 +2,7 @@ namespace Anikeen\Id\Resources; +use Anikeen\Id\Enums\Role; use DateTimeImmutable; use JsonSerializable; @@ -27,6 +28,7 @@ use JsonSerializable; * @property ?string $created_at REST shapes * @property ?string $pending_email profile updates only: a new address waiting for confirmation * @property ?string $pending_email_expires_at profile updates only + * @property ?string $role trusted apps only: Role::ADMIN or Role::USER */ class User implements JsonSerializable { @@ -97,6 +99,22 @@ class User implements JsonSerializable return (int) ($this->data->profile_version ?? $this->data->version ?? 0); } + /** + * Role::ADMIN or Role::USER; null for apps that aren't trusted (they never see it). + */ + public function role(): ?string + { + return $this->data->role ?? null; + } + + /** + * An Anikeen ID admin (trusted apps only; false when the role isn't known). + */ + public function isAdmin(): bool + { + return $this->role() === Role::ADMIN; + } + /** * A new email address waiting for the user's confirmation (profile updates only); `email` * stays the current address until then. diff --git a/src/Id/Socialite/Provider.php b/src/Id/Socialite/Provider.php index eedb300..1ee9690 100644 --- a/src/Id/Socialite/Provider.php +++ b/src/Id/Socialite/Provider.php @@ -83,6 +83,8 @@ class Provider extends AbstractProvider implements ProviderInterface 'name' => $user['name'], 'email' => Arr::get($user, 'email'), 'avatar' => $user['avatar'], + // trusted apps only (Enums\Role), null for others: $socialiteUser->role + 'role' => Arr::get($user, 'role'), ]); } diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php index 4806bf0..11838ad 100644 --- a/src/Id/Webhooks/EventType.php +++ b/src/Id/Webhooks/EventType.php @@ -92,7 +92,8 @@ final class EventType * the changed fields (never their old values). An email address appears only once verified * (`email` is null while it isn't); `avatar` in `changed` means the picture behind the stable URL * changed. Keep the highest `version` and ignore older events. Opt-in (not through `*`); also - * sent to the app that made the change. + * sent to the app that made the change. Trusted apps also get `role` (Enums\Role) and `role` in + * `changed`; a change of the role alone reaches only them. */ public const USER_UPDATED = 'user.updated'; diff --git a/tests/Id/UserRoleTest.php b/tests/Id/UserRoleTest.php new file mode 100644 index 0000000..e8c6081 --- /dev/null +++ b/tests/Id/UserRoleTest.php @@ -0,0 +1,55 @@ +invoke($provider, $raw); + } + + public function test_the_socialite_user_carries_the_role(): void + { + $raw = ['id' => '01JUSER', 'username' => 'ada', 'name' => 'Ada', 'email' => 'ada@example.com', 'avatar' => null]; + + $this->assertSame(Role::ADMIN, $this->socialiteUser([...$raw, 'role' => 'admin'])->role); + // apps that aren't trusted never get it + $this->assertNull($this->socialiteUser($raw)->role); + } + + public function test_a_user_read_by_id_has_the_role(): void + { + $this->respond(200, ['data' => ['id' => '01JUSER', 'name' => 'Ada', 'role' => 'admin']]); + + $user = app(AnikeenId::class)->findUserById('01JUSER'); + + $this->assertSame(Role::ADMIN, $user->role()); + $this->assertTrue($user->isAdmin()); + } + + public function test_the_webhook_object_has_it_for_trusted_apps_only(): void + { + $trusted = new User(['object' => 'user', 'id' => '01JUSER', 'version' => 3, 'role' => 'user']); + $other = new User(['object' => 'user', 'id' => '01JUSER', 'version' => 3]); + + $this->assertSame(Role::USER, $trusted->role()); + $this->assertFalse($trusted->isAdmin()); + $this->assertNull($other->role()); + $this->assertFalse($other->isAdmin()); + $this->assertSame([Role::ADMIN, Role::USER], Role::all()); + } +} From ee05bf8aa5be22a7bdb2c0553855f86927e847a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sun, 8 Nov 2026 11:12:51 +0000 Subject: [PATCH 2/2] Release 3.5.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f8795..88416e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 3.5.0 (2026-11-08) The user's role for trusted apps; it needs an Anikeen ID release that sends it.