From 2dd2d4027d7f37573bca0829eb4b5befb7d84a38 Mon Sep 17 00:00:00 2001 From: Maarten Bode Date: Sun, 14 Jun 2026 19:34:07 +0200 Subject: [PATCH] feat(api): add REST API with scoped access tokens and OpenAPI docs --- .../Activity/Contracts/Enums/ActivityType.php | 5 +- .../Actions/CreateRepositoryAction.php | 74 + .../Http/Controllers/RepositoryController.php | 63 +- .../Token/Actions/CreateAccessTokenAction.php | 9 +- .../Token/Actions/UpdateAccessTokenAction.php | 45 + .../Token/Contracts/Data/AccessTokenData.php | 5 + .../Token/Contracts/Data/TokenCreatedData.php | 4 + .../Token/Contracts/Enums/TokenScope.php | 127 ++ .../Http/Controllers/TokenController.php | 26 +- .../Http/Controllers/UserTokenController.php | 29 +- .../Requests/StoreAccessTokenRequest.php | 4 + .../Requests/UpdateAccessTokenRequest.php | 23 + .../Token/Services/AccessTokenResolver.php | 65 + .../Token/Services/TokenScopeChecker.php | 39 + app/Http/Controllers/Api/V1/ApiController.php | 46 + .../Api/V1/InvitationController.php | 50 + .../Controllers/Api/V1/MemberController.php | 117 + .../Controllers/Api/V1/MirrorController.php | 107 + .../Api/V1/OrganizationController.php | 90 + .../Controllers/Api/V1/PackageController.php | 68 + .../Api/V1/PackageVersionController.php | 51 + .../Api/V1/RepositoryController.php | 126 ++ .../Controllers/Api/V1/TokenController.php | 114 + .../Controllers/Api/V1/UserController.php | 51 + .../Api/V1/UserTokenController.php | 117 + app/Http/Middleware/ApiTokenAuth.php | 86 + app/Http/Middleware/ComposerTokenAuth.php | 65 +- app/Http/Middleware/RequireTokenScope.php | 53 + app/Providers/AppServiceProvider.php | 15 + bootstrap/app.php | 9 + composer.json | 4 + composer.lock | 202 +- config/scramble.php | 181 ++ database/factories/AccessTokenFactory.php | 18 +- docs/api/index.md | 75 +- docs/public/openapi.json | 1990 +++++++++++++++++ .../js/components/create-token-dialog.tsx | 96 +- resources/js/components/edit-token-dialog.tsx | 174 ++ .../js/components/token-created-dialog.tsx | 134 +- resources/js/components/token-list.tsx | 70 +- .../layouts/organization-settings-layout.tsx | 4 +- resources/js/layouts/settings/layout.tsx | 2 +- resources/js/lib/token-scopes.ts | 57 + .../pages/organizations/settings/tokens.tsx | 42 +- resources/js/pages/settings/tokens.tsx | 44 +- resources/types/generated.d.ts | 7 +- routes/api.php | 113 + routes/settings.php | 1 + routes/web.php | 1 + tests/Feature/Api/V1/AuthTest.php | 63 + tests/Feature/Api/V1/MemberApiTest.php | 58 + tests/Feature/Api/V1/OpenApiSpecTest.php | 29 + tests/Feature/Api/V1/OrganizationApiTest.php | 103 + tests/Feature/Api/V1/RepositoryApiTest.php | 78 + tests/Feature/Api/V1/ScopeEnforcementTest.php | 41 + tests/Feature/Api/V1/TokenApiTest.php | 138 ++ tests/Feature/Api/V1/TokenContextTest.php | 75 + tests/Feature/Composer/ComposerScopeTest.php | 32 + tests/Feature/Settings/UserTokenScopeTest.php | 50 + tests/Pest.php | 55 + 60 files changed, 5375 insertions(+), 245 deletions(-) create mode 100644 app/Domains/Repository/Actions/CreateRepositoryAction.php create mode 100644 app/Domains/Token/Actions/UpdateAccessTokenAction.php create mode 100644 app/Domains/Token/Contracts/Enums/TokenScope.php create mode 100644 app/Domains/Token/Requests/UpdateAccessTokenRequest.php create mode 100644 app/Domains/Token/Services/AccessTokenResolver.php create mode 100644 app/Domains/Token/Services/TokenScopeChecker.php create mode 100644 app/Http/Controllers/Api/V1/ApiController.php create mode 100644 app/Http/Controllers/Api/V1/InvitationController.php create mode 100644 app/Http/Controllers/Api/V1/MemberController.php create mode 100644 app/Http/Controllers/Api/V1/MirrorController.php create mode 100644 app/Http/Controllers/Api/V1/OrganizationController.php create mode 100644 app/Http/Controllers/Api/V1/PackageController.php create mode 100644 app/Http/Controllers/Api/V1/PackageVersionController.php create mode 100644 app/Http/Controllers/Api/V1/RepositoryController.php create mode 100644 app/Http/Controllers/Api/V1/TokenController.php create mode 100644 app/Http/Controllers/Api/V1/UserController.php create mode 100644 app/Http/Controllers/Api/V1/UserTokenController.php create mode 100644 app/Http/Middleware/ApiTokenAuth.php create mode 100644 app/Http/Middleware/RequireTokenScope.php create mode 100644 config/scramble.php create mode 100644 docs/public/openapi.json create mode 100644 resources/js/components/edit-token-dialog.tsx create mode 100644 resources/js/lib/token-scopes.ts create mode 100644 routes/api.php create mode 100644 tests/Feature/Api/V1/AuthTest.php create mode 100644 tests/Feature/Api/V1/MemberApiTest.php create mode 100644 tests/Feature/Api/V1/OpenApiSpecTest.php create mode 100644 tests/Feature/Api/V1/OrganizationApiTest.php create mode 100644 tests/Feature/Api/V1/RepositoryApiTest.php create mode 100644 tests/Feature/Api/V1/ScopeEnforcementTest.php create mode 100644 tests/Feature/Api/V1/TokenApiTest.php create mode 100644 tests/Feature/Api/V1/TokenContextTest.php create mode 100644 tests/Feature/Composer/ComposerScopeTest.php create mode 100644 tests/Feature/Settings/UserTokenScopeTest.php diff --git a/app/Domains/Activity/Contracts/Enums/ActivityType.php b/app/Domains/Activity/Contracts/Enums/ActivityType.php index c923386..93d722e 100644 --- a/app/Domains/Activity/Contracts/Enums/ActivityType.php +++ b/app/Domains/Activity/Contracts/Enums/ActivityType.php @@ -18,6 +18,7 @@ enum ActivityType: string case MemberRoleChanged = 'member.role_changed'; case InvitationSent = 'invitation.sent'; case TokenCreated = 'token.created'; + case TokenUpdated = 'token.updated'; case TokenRevoked = 'token.revoked'; case SshKeyGenerated = 'ssh_key.generated'; case SshKeyDeleted = 'ssh_key.deleted'; @@ -41,6 +42,7 @@ public function label(): string self::MemberRoleChanged => 'Role changed', self::InvitationSent => 'Invitation sent', self::TokenCreated => 'Token created', + self::TokenUpdated => 'Token updated', self::TokenRevoked => 'Token revoked', self::SshKeyGenerated => 'SSH key generated', self::SshKeyDeleted => 'SSH key deleted', @@ -66,6 +68,7 @@ public function icon(): string self::MemberRoleChanged => 'shield', self::InvitationSent => 'mail', self::TokenCreated => 'key-round', + self::TokenUpdated => 'key-round', self::TokenRevoked => 'key-round', self::SshKeyGenerated => 'key-round', self::SshKeyDeleted => 'key-round', @@ -83,7 +86,7 @@ public function category(): string self::RepositoryAdded, self::RepositoryRemoved, self::RepositorySynced, self::RepositorySyncFailed => 'repository', self::PackageCreated, self::PackageRemoved => 'package', self::MemberAdded, self::MemberRemoved, self::MemberRoleChanged, self::InvitationSent => 'member', - self::TokenCreated, self::TokenRevoked => 'token', + self::TokenCreated, self::TokenUpdated, self::TokenRevoked => 'token', self::SshKeyGenerated, self::SshKeyDeleted => 'settings', self::MirrorAdded, self::MirrorRemoved, self::MirrorSynced, self::MirrorSyncFailed => 'mirror', self::VulnerabilitiesDetected => 'security', diff --git a/app/Domains/Repository/Actions/CreateRepositoryAction.php b/app/Domains/Repository/Actions/CreateRepositoryAction.php new file mode 100644 index 0000000..7ff1fba --- /dev/null +++ b/app/Domains/Repository/Actions/CreateRepositoryAction.php @@ -0,0 +1,74 @@ +extractRepositoryName->handle($repoIdentifier, $provider); + + $isGenericGit = $provider === GitProvider::Git; + + $repository = Repository::create([ + 'organization_uuid' => $organization->uuid, + 'credential_user_uuid' => $isGenericGit ? null : $user->uuid, + 'ssh_key_uuid' => $isGenericGit ? $sshKeyUuid : null, + 'name' => $name, + 'provider' => $provider, + 'repo_identifier' => $repoIdentifier, + 'custom_base_url' => $this->resolveBaseUrl($provider, $user->uuid), + 'default_branch' => $defaultBranch, + ]); + + $this->recordActivity->handle( + organization: $organization, + type: ActivityType::RepositoryAdded, + subject: $repository, + actor: $user, + properties: ['name' => $repository->name, 'provider' => $repository->provider->value], + ); + + SyncRepositoryJob::dispatch($repository); + + $this->registerWebhook->handle($repository); + + return $repository; + } + + private function resolveBaseUrl(GitProvider $provider, string $userUuid): ?string + { + if (! $provider->supportsSelfHosted()) { + return null; + } + + $credential = UserGitCredential::query() + ->where('user_uuid', $userUuid) + ->where('provider', $provider) + ->first(); + + return $credential?->credentials['url'] ?? null; + } +} diff --git a/app/Domains/Repository/Http/Controllers/RepositoryController.php b/app/Domains/Repository/Http/Controllers/RepositoryController.php index 512ac08..0ebbf47 100644 --- a/app/Domains/Repository/Http/Controllers/RepositoryController.php +++ b/app/Domains/Repository/Http/Controllers/RepositoryController.php @@ -8,20 +8,17 @@ use App\Domains\Organization\Contracts\Data\OrganizationSshKeyData; use App\Domains\Package\Contracts\Data\PackageData; use App\Domains\Repository\Actions\BulkCreateRepositoriesAction; +use App\Domains\Repository\Actions\CreateRepositoryAction; use App\Domains\Repository\Actions\DeleteWebhookAction; -use App\Domains\Repository\Actions\ExtractRepositoryNameAction; -use App\Domains\Repository\Actions\RegisterWebhookAction; use App\Domains\Repository\Contracts\Data\RepositoryData; use App\Domains\Repository\Contracts\Data\SyncLogData; use App\Domains\Repository\Contracts\Enums\GitProvider; use App\Domains\Repository\Http\Requests\BulkStoreRepositoryRequest; use App\Domains\Repository\Http\Requests\StoreRepositoryRequest; -use App\Domains\Repository\Jobs\SyncRepositoryJob; use App\Http\Controllers\Controller; use App\Models\Organization; use App\Models\Repository; use App\Models\User; -use App\Models\UserGitCredential; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -33,8 +30,6 @@ class RepositoryController extends Controller use AuthorizesRequests; public function __construct( - protected ExtractRepositoryNameAction $extractRepositoryNameAction, - protected RegisterWebhookAction $registerWebhookAction, protected DeleteWebhookAction $deleteWebhookAction, protected RecordActivityTask $recordActivity, ) {} @@ -70,49 +65,25 @@ public function index(Organization $organization): Response ]); } - public function store(StoreRepositoryRequest $request, Organization $organization): RedirectResponse + public function store(StoreRepositoryRequest $request, Organization $organization, CreateRepositoryAction $createRepository): RedirectResponse { $this->authorize('deleteRepository', $organization); - $name = $request->name ?? $this->extractRepositoryNameAction->handle( - $request->repo_identifier, - GitProvider::from($request->provider) - ); - - $provider = GitProvider::from($request->provider); - /** @var User $user */ $user = auth()->user(); - $baseUrl = $this->resolveBaseUrl($provider, $user->uuid); - - $isGenericGit = $provider === GitProvider::Git; - - $repository = Repository::create([ - 'organization_uuid' => $organization->uuid, - 'credential_user_uuid' => $isGenericGit ? null : $user->uuid, - 'ssh_key_uuid' => $isGenericGit ? $request->ssh_key_uuid : null, - 'name' => $name, - 'provider' => $provider, - 'repo_identifier' => $request->repo_identifier, - 'custom_base_url' => $baseUrl, - 'default_branch' => $request->default_branch, - ]); - - $this->recordActivity->handle( + $repository = $createRepository->handle( organization: $organization, - type: ActivityType::RepositoryAdded, - subject: $repository, - actor: $request->user(), - properties: ['name' => $repository->name, 'provider' => $repository->provider->value], + provider: GitProvider::from($request->provider), + repoIdentifier: $request->repo_identifier, + user: $user, + name: $request->name, + defaultBranch: $request->default_branch, + sshKeyUuid: $request->ssh_key_uuid, ); - SyncRepositoryJob::dispatch($repository); - - $webhookRegistered = $this->registerWebhookAction->handle($repository); - $message = 'Repository added successfully.'; - if (! $webhookRegistered && $repository->provider->supportsAutomaticWebhooks()) { + if ($repository->webhook_id === null && $repository->provider->supportsAutomaticWebhooks()) { $message .= ' Webhook registration failed — you can retry from the repository page.'; } @@ -223,18 +194,4 @@ public function destroy(Request $request, Organization $organization, Repository ->route('organizations.repositories.index', $organization) ->with('status', 'Repository deleted successfully.'); } - - private function resolveBaseUrl(GitProvider $provider, string $userUuid): ?string - { - if (! $provider->supportsSelfHosted()) { - return null; - } - - $credential = UserGitCredential::query() - ->where('user_uuid', $userUuid) - ->where('provider', $provider) - ->first(); - - return $credential?->credentials['url'] ?? null; - } } diff --git a/app/Domains/Token/Actions/CreateAccessTokenAction.php b/app/Domains/Token/Actions/CreateAccessTokenAction.php index 817db72..9c5604c 100644 --- a/app/Domains/Token/Actions/CreateAccessTokenAction.php +++ b/app/Domains/Token/Actions/CreateAccessTokenAction.php @@ -5,6 +5,7 @@ use App\Domains\Activity\Actions\RecordActivityTask; use App\Domains\Activity\Contracts\Enums\ActivityType; use App\Domains\Token\Contracts\Data\TokenCreatedData; +use App\Domains\Token\Contracts\Enums\TokenScope; use App\Models\AccessToken; use App\Models\Organization; use App\Models\User; @@ -17,11 +18,15 @@ public function __construct( protected RecordActivityTask $recordActivity, ) {} + /** + * @param array|null $scopes Null grants full (legacy) access. + */ public function handle( ?Organization $organization, ?User $user, string $name, - ?Carbon $expiresAt = null + ?Carbon $expiresAt = null, + ?array $scopes = null, ): TokenCreatedData { $plainToken = Str::random(64); $tokenHash = hash('sha256', $plainToken); @@ -32,6 +37,7 @@ public function handle( 'name' => $name, 'token_hash' => $tokenHash, 'expires_at' => $expiresAt, + 'scopes' => $scopes !== null ? TokenScope::normalize($scopes) : null, ]); if ($organization) { @@ -49,6 +55,7 @@ public function handle( name: $name, expiresAt: $accessToken->expires_at, organizationUuid: $accessToken->organization_uuid, + scopes: $accessToken->scopes ?? [], ); } } diff --git a/app/Domains/Token/Actions/UpdateAccessTokenAction.php b/app/Domains/Token/Actions/UpdateAccessTokenAction.php new file mode 100644 index 0000000..b2208f0 --- /dev/null +++ b/app/Domains/Token/Actions/UpdateAccessTokenAction.php @@ -0,0 +1,45 @@ +|null $scopes + */ + public function handle(AccessToken $accessToken, string $name, ?array $scopes = null, ?User $actor = null): AccessToken + { + $attributes = ['name' => $name]; + + if ($scopes !== null) { + $attributes['scopes'] = TokenScope::normalize($scopes); + } + + $accessToken->update($attributes); + + if ($accessToken->organization) { + $this->recordActivity->handle( + organization: $accessToken->organization, + type: ActivityType::TokenUpdated, + subject: $accessToken, + actor: $actor ?? auth()->user(), + properties: ['name' => $accessToken->name], + ); + } + + return $accessToken; + } +} diff --git a/app/Domains/Token/Contracts/Data/AccessTokenData.php b/app/Domains/Token/Contracts/Data/AccessTokenData.php index 442abb5..e62a662 100644 --- a/app/Domains/Token/Contracts/Data/AccessTokenData.php +++ b/app/Domains/Token/Contracts/Data/AccessTokenData.php @@ -10,12 +10,16 @@ #[TypeScript] class AccessTokenData extends Data { + /** + * @param array $scopes + */ public function __construct( public string $uuid, public string $name, public ?CarbonInterface $lastUsedAt, public ?CarbonInterface $expiresAt, public CarbonInterface $createdAt, + public array $scopes = [], ) {} public static function fromModel(AccessToken $token): self @@ -32,6 +36,7 @@ public static function fromModel(AccessToken $token): self lastUsedAt: $token->last_used_at, expiresAt: $token->expires_at, createdAt: $createdAt, + scopes: $token->scopes ?? [], ); } } diff --git a/app/Domains/Token/Contracts/Data/TokenCreatedData.php b/app/Domains/Token/Contracts/Data/TokenCreatedData.php index ba511b7..6c2d396 100644 --- a/app/Domains/Token/Contracts/Data/TokenCreatedData.php +++ b/app/Domains/Token/Contracts/Data/TokenCreatedData.php @@ -9,10 +9,14 @@ #[TypeScript] class TokenCreatedData extends Data { + /** + * @param array $scopes + */ public function __construct( public string $plainToken, public string $name, public ?CarbonInterface $expiresAt, public ?string $organizationUuid, + public array $scopes = [], ) {} } diff --git a/app/Domains/Token/Contracts/Enums/TokenScope.php b/app/Domains/Token/Contracts/Enums/TokenScope.php new file mode 100644 index 0000000..d2db631 --- /dev/null +++ b/app/Domains/Token/Contracts/Enums/TokenScope.php @@ -0,0 +1,127 @@ + 'Composer registry access', + self::ReadOrganizations => 'Read organizations', + self::WriteOrganizations => 'Create & update organizations', + self::DeleteOrganizations => 'Delete organizations', + self::ReadRepositories => 'Read repositories', + self::WriteRepositories => 'Add & sync repositories', + self::DeleteRepositories => 'Delete repositories', + self::ReadPackages => 'Read packages', + self::WritePackages => 'Manage packages', + self::DeletePackages => 'Delete packages & versions', + self::ReadMembers => 'Read members & invitations', + self::WriteMembers => 'Manage members & invitations', + self::ReadMirrors => 'Read mirrors', + self::WriteMirrors => 'Add & sync mirrors', + self::DeleteMirrors => 'Delete mirrors', + self::ReadTokens => 'Read access tokens', + self::WriteTokens => 'Create & revoke access tokens', + }; + } + + /** + * The resource group this scope belongs to (used to group scopes in the UI). + */ + public function resource(): string + { + return match ($this) { + self::Composer => 'composer', + self::ReadOrganizations, self::WriteOrganizations, self::DeleteOrganizations => 'organizations', + self::ReadRepositories, self::WriteRepositories, self::DeleteRepositories => 'repositories', + self::ReadPackages, self::WritePackages, self::DeletePackages => 'packages', + self::ReadMembers, self::WriteMembers => 'members', + self::ReadMirrors, self::WriteMirrors, self::DeleteMirrors => 'mirrors', + self::ReadTokens, self::WriteTokens => 'tokens', + }; + } + + /** + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $scope) { + $options[$scope->value] = $scope->label(); + } + + return $options; + } + + /** + * Scopes grouped by resource for grouped UI rendering. + * + * @return array> + */ + public static function grouped(): array + { + $grouped = []; + + foreach (self::cases() as $scope) { + $grouped[$scope->resource()][$scope->value] = $scope->label(); + } + + return $grouped; + } + + /** + * Default scopes for a token used purely for Composer registry access. + * + * @return array + */ + public static function composerDefault(): array + { + return [self::Composer]; + } + + /** + * Normalize a mixed list of scopes into a deduplicated list of string values. + * + * @param array $scopes + * @return array + */ + public static function normalize(array $scopes): array + { + return array_values(array_unique(array_map( + fn (self|string $scope) => $scope instanceof self ? $scope->value : $scope, + $scopes, + ))); + } +} diff --git a/app/Domains/Token/Http/Controllers/TokenController.php b/app/Domains/Token/Http/Controllers/TokenController.php index 05a5f79..19274a6 100644 --- a/app/Domains/Token/Http/Controllers/TokenController.php +++ b/app/Domains/Token/Http/Controllers/TokenController.php @@ -6,8 +6,11 @@ use App\Domains\Activity\Contracts\Enums\ActivityType; use App\Domains\Organization\Contracts\Data\OrganizationData; use App\Domains\Token\Actions\CreateAccessTokenAction; +use App\Domains\Token\Actions\UpdateAccessTokenAction; use App\Domains\Token\Contracts\Data\AccessTokenData; +use App\Domains\Token\Contracts\Enums\TokenScope; use App\Domains\Token\Requests\StoreAccessTokenRequest; +use App\Domains\Token\Requests\UpdateAccessTokenRequest; use App\Http\Controllers\Controller; use App\Models\AccessToken; use App\Models\Organization; @@ -22,6 +25,7 @@ class TokenController extends Controller public function __construct( protected CreateAccessTokenAction $createAccessToken, + protected UpdateAccessTokenAction $updateAccessToken, protected RecordActivityTask $recordActivity, ) {} @@ -49,7 +53,8 @@ public function store(StoreAccessTokenRequest $request, Organization $organizati organization: $organization, user: null, name: $request->validated('name'), - expiresAt: $request->validated('expires_at') ? now()->parse($request->validated('expires_at')) : null + expiresAt: $request->validated('expires_at') ? now()->parse($request->validated('expires_at')) : null, + scopes: $request->validated('scopes') ?? [TokenScope::Composer->value], ); $tokens = AccessToken::query() @@ -65,6 +70,25 @@ public function store(StoreAccessTokenRequest $request, Organization $organizati ]); } + public function update(UpdateAccessTokenRequest $request, Organization $organization, AccessToken $token): RedirectResponse + { + $this->authorize('viewSettings', $organization); + + if ($token->organization_uuid !== $organization->uuid) { + abort(403); + } + + $this->updateAccessToken->handle( + accessToken: $token, + name: $request->validated('name'), + scopes: $request->validated('scopes'), + actor: $request->user(), + ); + + return to_route('organizations.settings.tokens.index', $organization) + ->with('status', 'Token updated successfully.'); + } + public function destroy(Organization $organization, AccessToken $token): RedirectResponse { $this->authorize('viewSettings', $organization); diff --git a/app/Domains/Token/Http/Controllers/UserTokenController.php b/app/Domains/Token/Http/Controllers/UserTokenController.php index 08b169d..7ca421d 100644 --- a/app/Domains/Token/Http/Controllers/UserTokenController.php +++ b/app/Domains/Token/Http/Controllers/UserTokenController.php @@ -3,8 +3,11 @@ namespace App\Domains\Token\Http\Controllers; use App\Domains\Token\Actions\CreateAccessTokenAction; +use App\Domains\Token\Actions\UpdateAccessTokenAction; use App\Domains\Token\Contracts\Data\AccessTokenData; +use App\Domains\Token\Contracts\Enums\TokenScope; use App\Domains\Token\Requests\StoreAccessTokenRequest; +use App\Domains\Token\Requests\UpdateAccessTokenRequest; use App\Http\Controllers\Controller; use App\Models\AccessToken; use App\Models\User; @@ -16,7 +19,8 @@ class UserTokenController extends Controller { public function __construct( - protected CreateAccessTokenAction $createAccessToken + protected CreateAccessTokenAction $createAccessToken, + protected UpdateAccessTokenAction $updateAccessToken, ) {} public function index(Request $request): Response @@ -44,7 +48,8 @@ public function store(StoreAccessTokenRequest $request): Response organization: null, user: $user, name: $request->validated('name'), - expiresAt: $request->validated('expires_at') ? now()->parse($request->validated('expires_at')) : null + expiresAt: $request->validated('expires_at') ? now()->parse($request->validated('expires_at')) : null, + scopes: $request->validated('scopes') ?? [TokenScope::Composer->value], ); $tokens = AccessToken::query() @@ -59,6 +64,26 @@ public function store(StoreAccessTokenRequest $request): Response ]); } + public function update(UpdateAccessTokenRequest $request, AccessToken $token): RedirectResponse + { + /** @var User $user */ + $user = $request->user(); + + if ($token->user_uuid !== $user->uuid) { + abort(403); + } + + $this->updateAccessToken->handle( + accessToken: $token, + name: $request->validated('name'), + scopes: $request->validated('scopes'), + actor: $user, + ); + + return to_route('settings.tokens.index') + ->with('status', 'Token updated successfully.'); + } + public function destroy(Request $request, AccessToken $token): RedirectResponse { /** @var User $user */ diff --git a/app/Domains/Token/Requests/StoreAccessTokenRequest.php b/app/Domains/Token/Requests/StoreAccessTokenRequest.php index 235da92..45f7089 100644 --- a/app/Domains/Token/Requests/StoreAccessTokenRequest.php +++ b/app/Domains/Token/Requests/StoreAccessTokenRequest.php @@ -2,8 +2,10 @@ namespace App\Domains\Token\Requests; +use App\Domains\Token\Contracts\Enums\TokenScope; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StoreAccessTokenRequest extends FormRequest { @@ -15,6 +17,8 @@ public function rules(): array return [ 'name' => ['required', 'string', 'max:255'], 'expires_at' => ['nullable', 'string'], + 'scopes' => ['nullable', 'array'], + 'scopes.*' => [Rule::enum(TokenScope::class)], ]; } diff --git a/app/Domains/Token/Requests/UpdateAccessTokenRequest.php b/app/Domains/Token/Requests/UpdateAccessTokenRequest.php new file mode 100644 index 0000000..4662dbb --- /dev/null +++ b/app/Domains/Token/Requests/UpdateAccessTokenRequest.php @@ -0,0 +1,23 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'scopes' => ['nullable', 'array'], + 'scopes.*' => [Rule::enum(TokenScope::class)], + ]; + } +} diff --git a/app/Domains/Token/Services/AccessTokenResolver.php b/app/Domains/Token/Services/AccessTokenResolver.php new file mode 100644 index 0000000..0ee7530 --- /dev/null +++ b/app/Domains/Token/Services/AccessTokenResolver.php @@ -0,0 +1,65 @@ +extractToken($request); + + if (! $token) { + return null; + } + + return $this->findAccessToken($token); + } + + /** + * Extract the plaintext token from a Bearer or Basic Authorization header. + */ + public function extractToken(Request $request): ?string + { + $header = $request->header('Authorization'); + + if (! $header) { + return null; + } + + // Bearer token + if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches)) { + return $matches[1]; + } + + // Basic auth (token as password, username is ignored) + if (preg_match('/^Basic\s+(.+)$/i', $header, $matches)) { + $decoded = base64_decode($matches[1], true); + if ($decoded === false) { + return null; + } + + // Basic auth format: username:password — the token is in the password field. + $parts = explode(':', $decoded, 2); + + return $parts[1] ?? null; + } + + return null; + } + + public function findAccessToken(string $token): ?AccessToken + { + $tokenHash = hash('sha256', $token); + + return AccessToken::query() + ->where('token_hash', $tokenHash) + ->with(['organization', 'user']) + ->first(); + } +} diff --git a/app/Domains/Token/Services/TokenScopeChecker.php b/app/Domains/Token/Services/TokenScopeChecker.php new file mode 100644 index 0000000..53f4cd2 --- /dev/null +++ b/app/Domains/Token/Services/TokenScopeChecker.php @@ -0,0 +1,39 @@ +scopes === null) { + return true; + } + + return in_array($scope->value, $token->scopes, true); + } + + /** + * Determine whether the token carries any of the given scopes. + */ + public function hasAny(AccessToken $token, TokenScope ...$scopes): bool + { + foreach ($scopes as $scope) { + if ($this->hasScope($token, $scope)) { + return true; + } + } + + return false; + } +} diff --git a/app/Http/Controllers/Api/V1/ApiController.php b/app/Http/Controllers/Api/V1/ApiController.php new file mode 100644 index 0000000..89b36a0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApiController.php @@ -0,0 +1,46 @@ +get('accessToken'); + + abort_unless($accessToken instanceof AccessToken, 401); + + return $accessToken; + } + + /** + * Ensure the request is authenticated with a personal (user) access token, + * not an organization-scoped token. + */ + protected function requirePersonalAccessToken(Request $request): void + { + abort_if( + $this->accessToken($request)->organization_uuid !== null, + 403, + 'This endpoint requires a personal access token.', + ); + } + + /** + * Resolve the requested page size, clamped to a sensible range. + */ + protected function perPage(Request $request): int + { + return min(max($request->integer('per_page', 25), 1), 100); + } +} diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php new file mode 100644 index 0000000..f071887 --- /dev/null +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -0,0 +1,50 @@ + + */ + public function index(Request $request, Organization $organization): PaginatedDataCollection + { + $this->authorize('viewSettings', $organization); + + $invitations = $organization->invitations() + ->orderBy('created_at', 'desc') + ->paginate($this->perPage($request)) + ->through(fn ($invitation) => OrganizationInvitationData::fromModel($invitation)); + + return OrganizationInvitationData::collect($invitations, PaginatedDataCollection::class); + } + + public function resend(Organization $organization, OrganizationInvitation $invitation, ResendOrganizationInvitationAction $resend): OrganizationInvitationData + { + $this->authorize('manageMembers', $organization); + abort_unless($invitation->organization_uuid === $organization->uuid, 404); + abort_if($invitation->isAccepted(), 422, 'This invitation has already been accepted.'); + + $resend->handle($invitation); + + return OrganizationInvitationData::fromModel($invitation->refresh()); + } + + public function destroy(Organization $organization, OrganizationInvitation $invitation): Response + { + $this->authorize('manageMembers', $organization); + abort_unless($invitation->organization_uuid === $organization->uuid, 404); + + $invitation->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php new file mode 100644 index 0000000..73c59e9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -0,0 +1,117 @@ + + */ + public function index(Request $request, Organization $organization): PaginatedDataCollection + { + $this->authorize('viewSettings', $organization); + + $members = $organization->members() + ->orderBy('name') + ->paginate($this->perPage($request)) + ->through(fn (User $user) => OrganizationMemberData::fromUserAndPivot($user, $user->pivot)); + + return OrganizationMemberData::collect($members, PaginatedDataCollection::class); + } + + public function store(AddMemberRequest $request, Organization $organization, SendOrganizationInvitationAction $sendInvitation): OrganizationInvitationData + { + $this->authorize('manageMembers', $organization); + + $email = $request->validated('email'); + $role = OrganizationRole::from($request->validated('role')); + + $existingUser = User::where('email', $email)->first(); + if ($existingUser && $organization->members()->where('user_uuid', $existingUser->uuid)->exists()) { + abort(422, 'User is already a member of this organization.'); + } + + if ($organization->pendingInvitations()->where('email', $email)->exists()) { + abort(422, 'An invitation has already been sent to this email address.'); + } + + /** @var User $invitedBy */ + $invitedBy = $request->user(); + + $invitation = $sendInvitation->handle($organization, $email, $role, $invitedBy); + + return OrganizationInvitationData::fromModel($invitation); + } + + public function update(UpdateMemberRoleRequest $request, Organization $organization, OrganizationUser $member, RecordActivityTask $recordActivity): OrganizationMemberData + { + $this->authorize('manageMembers', $organization); + abort_unless($member->organization_uuid === $organization->uuid, 404); + abort_if($member->role->isOwner(), 422, 'Cannot change the role of the organization owner.'); + + $oldRole = $member->role->value; + $member->update(['role' => $request->validated('role')]); + + $memberUser = $member->user; + + $recordActivity->handle( + organization: $organization, + type: ActivityType::MemberRoleChanged, + subject: $memberUser, + actor: $request->user(), + properties: [ + 'member_name' => $memberUser->name, + 'old_role' => $oldRole, + 'new_role' => $request->validated('role'), + ], + ); + + return new OrganizationMemberData( + uuid: $member->uuid, + name: $memberUser->name, + email: $memberUser->email, + avatar: $memberUser->avatar, + role: $member->role, + joinedAt: $member->created_at, + ); + } + + public function destroy(Request $request, Organization $organization, OrganizationUser $member, RecordActivityTask $recordActivity): Response + { + $this->authorize('manageMembers', $organization); + abort_unless($member->organization_uuid === $organization->uuid, 404); + abort_if($member->role->isOwner(), 422, 'Cannot remove the organization owner.'); + + $memberUser = $member->user; + + $recordActivity->handle( + organization: $organization, + type: ActivityType::MemberRemoved, + subject: $memberUser, + actor: $request->user(), + properties: [ + 'member_name' => $memberUser->name, + 'member_email' => $memberUser->email, + ], + ); + + $member->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Api/V1/MirrorController.php b/app/Http/Controllers/Api/V1/MirrorController.php new file mode 100644 index 0000000..32e0394 --- /dev/null +++ b/app/Http/Controllers/Api/V1/MirrorController.php @@ -0,0 +1,107 @@ + + */ + public function index(Request $request, Organization $organization): PaginatedDataCollection + { + $this->authorize('viewSettings', $organization); + + $mirrors = $organization->mirrors() + ->withCount('packages') + ->orderBy('name') + ->paginate($this->perPage($request)) + ->through(fn ($mirror) => MirrorData::fromModel($mirror)); + + return MirrorData::collect($mirrors, PaginatedDataCollection::class); + } + + public function store(StoreMirrorRequest $request, Organization $organization): MirrorData + { + $this->authorize('viewSettings', $organization); + + $mirror = $organization->mirrors()->create([ + 'name' => $request->validated('name'), + 'url' => $request->validated('url'), + 'auth_type' => $request->validated('auth_type'), + 'auth_credentials' => $request->authCredentials(), + 'mirror_dist' => $request->validated('mirror_dist', true), + 'sync_status' => RepositorySyncStatus::Pending, + ]); + + $this->recordActivity->handle( + organization: $organization, + type: ActivityType::MirrorAdded, + subject: $mirror, + actor: $request->user(), + properties: ['name' => $mirror->name], + ); + + SyncMirrorJob::dispatch($mirror); + + return MirrorData::fromModel($mirror->loadCount('packages')); + } + + public function show(Organization $organization, Mirror $mirror): MirrorData + { + $this->authorize('viewSettings', $organization); + $this->ensureBelongsToOrganization($organization, $mirror); + + return MirrorData::fromModel($mirror->loadCount('packages')); + } + + public function sync(Organization $organization, Mirror $mirror): MirrorData + { + $this->authorize('viewSettings', $organization); + $this->ensureBelongsToOrganization($organization, $mirror); + + $mirror->update(['sync_status' => RepositorySyncStatus::Pending]); + + SyncMirrorJob::dispatch($mirror); + + return MirrorData::fromModel($mirror->loadCount('packages')); + } + + public function destroy(Request $request, Organization $organization, Mirror $mirror): Response + { + $this->authorize('viewSettings', $organization); + $this->ensureBelongsToOrganization($organization, $mirror); + + $this->recordActivity->handle( + organization: $organization, + type: ActivityType::MirrorRemoved, + subject: $mirror, + actor: $request->user(), + properties: ['name' => $mirror->name], + ); + + $mirror->delete(); + + return response()->noContent(); + } + + private function ensureBelongsToOrganization(Organization $organization, Mirror $mirror): void + { + abort_unless($mirror->organization_uuid === $organization->uuid, 404); + } +} diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php new file mode 100644 index 0000000..e263d86 --- /dev/null +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -0,0 +1,90 @@ + + */ + public function index(Request $request): PaginatedDataCollection + { + $accessToken = $this->accessToken($request); + + if ($accessToken->organization_uuid !== null) { + // An organization-scoped token can only ever see its own organization. + $query = Organization::query()->whereKey($accessToken->organization_uuid); + } else { + /** @var User $user */ + $user = $request->user(); + $query = $user->organizations()->getQuery(); + } + + $organizations = $query + ->orderBy('name') + ->paginate($this->perPage($request)) + ->through(fn ($organization) => OrganizationData::fromModel($organization)); + + return OrganizationData::collect($organizations, PaginatedDataCollection::class); + } + + /** + * Create a new organization owned by the authenticated user. + */ + public function store(StoreOrganizationRequest $request, CreateOrganizationAction $createOrganization): OrganizationData + { + $this->requirePersonalAccessToken($request); + + /** @var User $user */ + $user = $request->user(); + + return $createOrganization->handle( + name: $request->validated('name'), + ownerUuid: $user->uuid, + ); + } + + public function show(Request $request, Organization $organization): OrganizationData + { + $this->authorize('view', $organization); + + return OrganizationData::fromModel($organization); + } + + public function update(UpdateOrganizationRequest $request, Organization $organization): OrganizationData + { + $user = $request->user(); + $isOwner = $user !== null && $organization->owner_uuid === $user->uuid; + + $attributes = ['name' => $request->validated('name')]; + + if ($isOwner && $request->has('slug')) { + $attributes['slug'] = $request->validated('slug'); + } + + $organization->update($attributes); + + return OrganizationData::fromModel($organization->refresh()); + } + + public function destroy(Request $request, Organization $organization): Response + { + $this->authorize('delete', $organization); + + $organization->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Api/V1/PackageController.php b/app/Http/Controllers/Api/V1/PackageController.php new file mode 100644 index 0000000..7726447 --- /dev/null +++ b/app/Http/Controllers/Api/V1/PackageController.php @@ -0,0 +1,68 @@ + + */ + public function index(Request $request, Organization $organization): PaginatedDataCollection + { + $this->authorize('view', $organization); + + $packages = $organization->packages() + ->with(['repository', 'mirror']) + ->withCount('versions') + ->orderBy('name') + ->paginate($this->perPage($request)) + ->through(fn ($package) => PackageData::fromModel($package)); + + return PackageData::collect($packages, PaginatedDataCollection::class); + } + + public function show(Organization $organization, Package $package): PackageData + { + $this->authorize('view', $organization); + $this->ensureBelongsToOrganization($organization, $package); + + $package->load(['repository', 'mirror'])->loadCount('versions'); + + return PackageData::fromModel($package); + } + + public function destroy(Request $request, Organization $organization, Package $package, RecordActivityTask $recordActivity): Response + { + $this->authorize('deleteRepository', $organization); + $this->ensureBelongsToOrganization($organization, $package); + + $recordActivity->handle( + organization: $organization, + type: ActivityType::PackageRemoved, + subject: $package, + actor: $request->user(), + properties: ['name' => $package->name], + ); + + // Delete versions through Eloquent so dist files are cleaned up. + $package->versions()->each(fn ($version) => $version->delete()); + + $package->delete(); + + return response()->noContent(); + } + + private function ensureBelongsToOrganization(Organization $organization, Package $package): void + { + abort_unless($package->organization_uuid === $organization->uuid, 404); + } +} diff --git a/app/Http/Controllers/Api/V1/PackageVersionController.php b/app/Http/Controllers/Api/V1/PackageVersionController.php new file mode 100644 index 0000000..06bfdf2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/PackageVersionController.php @@ -0,0 +1,51 @@ + + */ + public function index(Request $request, Organization $organization, Package $package): PaginatedDataCollection + { + $this->authorize('view', $organization); + $this->ensurePackageBelongsToOrganization($organization, $package); + + $provider = $package->repository?->provider; + $repoIdentifier = $package->repository?->repo_identifier; + + $versions = $package->versions() + ->orderBySemanticVersion('desc') + ->paginate($this->perPage($request)) + ->through(fn ($version) => PackageVersionData::fromModel($version, $provider, $repoIdentifier)); + + return PackageVersionData::collect($versions, PaginatedDataCollection::class); + } + + public function destroy(Organization $organization, Package $package, PackageVersion $version): Response + { + $this->authorize('deleteRepository', $organization); + $this->ensurePackageBelongsToOrganization($organization, $package); + + abort_unless($version->package_uuid === $package->uuid, 404); + + // Triggers dist file cleanup via the model's deleting event. + $version->delete(); + + return response()->noContent(); + } + + private function ensurePackageBelongsToOrganization(Organization $organization, Package $package): void + { + abort_unless($package->organization_uuid === $organization->uuid, 404); + } +} diff --git a/app/Http/Controllers/Api/V1/RepositoryController.php b/app/Http/Controllers/Api/V1/RepositoryController.php new file mode 100644 index 0000000..e34e39e --- /dev/null +++ b/app/Http/Controllers/Api/V1/RepositoryController.php @@ -0,0 +1,126 @@ + + */ + public function index(Request $request, Organization $organization): PaginatedDataCollection + { + $this->authorize('view', $organization); + + $repositories = $organization->repositories() + ->withCount('packages') + ->orderBy('name') + ->paginate($this->perPage($request)) + ->through(fn ($repository) => RepositoryData::fromModel($repository)); + + return RepositoryData::collect($repositories, PaginatedDataCollection::class); + } + + public function store(StoreRepositoryRequest $request, Organization $organization, CreateRepositoryAction $createRepository): RepositoryData + { + $this->authorize('deleteRepository', $organization); + + /** @var User $user */ + $user = $request->user(); + + $repository = $createRepository->handle( + organization: $organization, + provider: GitProvider::from($request->validated('provider')), + repoIdentifier: $request->validated('repo_identifier'), + user: $user, + name: $request->validated('name'), + defaultBranch: $request->validated('default_branch'), + sshKeyUuid: $request->validated('ssh_key_uuid'), + ); + + return RepositoryData::fromModel($repository->loadCount('packages')); + } + + public function bulkStore(BulkStoreRepositoryRequest $request, Organization $organization, BulkCreateRepositoriesAction $bulkCreate): BulkImportResultData + { + $this->authorize('deleteRepository', $organization); + + /** @var User $user */ + $user = $request->user(); + + return $bulkCreate->handle( + organization: $organization, + provider: GitProvider::from($request->validated('provider')), + repositories: $request->validated('repositories'), + userUuid: $user->uuid, + ); + } + + public function show(Organization $organization, Repository $repository): RepositoryData + { + $this->authorize('view', $organization); + $this->ensureBelongsToOrganization($organization, $repository); + + return RepositoryData::fromModel($repository->loadCount('packages')); + } + + public function sync(Organization $organization, Repository $repository): RepositoryData + { + $this->authorize('deleteRepository', $organization); + $this->ensureBelongsToOrganization($organization, $repository); + + $repository->update(['sync_status' => RepositorySyncStatus::Pending]); + + SyncRepositoryJob::dispatch($repository); + + return RepositoryData::fromModel($repository->loadCount('packages')); + } + + public function destroy(Request $request, Organization $organization, Repository $repository): Response + { + $this->authorize('deleteRepository', $organization); + $this->ensureBelongsToOrganization($organization, $repository); + + $this->recordActivity->handle( + organization: $organization, + type: ActivityType::RepositoryRemoved, + subject: $repository, + actor: $request->user(), + properties: ['name' => $repository->name], + ); + + $this->deleteWebhook->handle($repository); + + $repository->delete(); + + return response()->noContent(); + } + + private function ensureBelongsToOrganization(Organization $organization, Repository $repository): void + { + abort_unless($repository->organization_uuid === $organization->uuid, 404); + } +} diff --git a/app/Http/Controllers/Api/V1/TokenController.php b/app/Http/Controllers/Api/V1/TokenController.php new file mode 100644 index 0000000..b3bf23f --- /dev/null +++ b/app/Http/Controllers/Api/V1/TokenController.php @@ -0,0 +1,114 @@ + + */ + public function index(Request $request, Organization $organization): PaginatedDataCollection + { + $this->authorize('viewSettings', $organization); + + $tokens = $organization->accessTokens() + ->orderBy('created_at', 'desc') + ->paginate($this->perPage($request)) + ->through(fn ($token) => AccessTokenData::fromModel($token)); + + return AccessTokenData::collect($tokens, PaginatedDataCollection::class); + } + + public function store(StoreAccessTokenRequest $request, Organization $organization): TokenCreatedData + { + $this->authorize('viewSettings', $organization); + + $scopes = $request->validated('scopes') ?? [TokenScope::Composer->value]; + $this->assertCanGrantScopes($request, $scopes); + + return $this->createAccessToken->handle( + organization: $organization, + user: null, + name: $request->validated('name'), + expiresAt: $request->validated('expires_at') ? now()->parse($request->validated('expires_at')) : null, + scopes: $scopes, + ); + } + + public function update(UpdateAccessTokenRequest $request, Organization $organization, AccessToken $token): AccessTokenData + { + $this->authorize('viewSettings', $organization); + abort_unless($token->organization_uuid === $organization->uuid, 404); + + $scopes = $request->validated('scopes'); + if ($scopes !== null) { + $this->assertCanGrantScopes($request, $scopes); + } + + $this->updateAccessToken->handle( + accessToken: $token, + name: $request->validated('name'), + scopes: $scopes, + actor: $request->user(), + ); + + return AccessTokenData::fromModel($token->refresh()); + } + + public function destroy(Request $request, Organization $organization, AccessToken $token): Response + { + $this->authorize('viewSettings', $organization); + abort_unless($token->organization_uuid === $organization->uuid, 404); + + $this->recordActivity->handle( + organization: $organization, + type: ActivityType::TokenRevoked, + subject: $token, + actor: $request->user(), + properties: ['name' => $token->name], + ); + + $token->delete(); + + return response()->noContent(); + } + + /** + * A token may only grant scopes it itself holds (legacy null-scope tokens may grant any). + * + * @param array $scopeValues + */ + private function assertCanGrantScopes(Request $request, array $scopeValues): void + { + $accessToken = $this->accessToken($request); + + foreach ($scopeValues as $value) { + if (! $this->scopeChecker->hasScope($accessToken, TokenScope::from($value))) { + abort(403, "Token cannot grant the '{$value}' scope it does not itself hold."); + } + } + } +} diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php new file mode 100644 index 0000000..00dbe64 --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -0,0 +1,51 @@ +requirePersonalAccessToken($request); + + /** @var User $user */ + $user = $request->user(); + + return [ + 'uuid' => $user->uuid, + 'name' => $user->name, + 'email' => $user->email, + 'avatar' => $user->avatar, + ]; + } + + /** + * List the organizations the authenticated user belongs to. + * + * @return PaginatedDataCollection + */ + public function organizations(Request $request): PaginatedDataCollection + { + $this->requirePersonalAccessToken($request); + + /** @var User $user */ + $user = $request->user(); + + $organizations = $user->organizations() + ->orderBy('name') + ->paginate($this->perPage($request)) + ->through(fn ($organization) => OrganizationData::fromModel($organization)); + + return OrganizationData::collect($organizations, PaginatedDataCollection::class); + } +} diff --git a/app/Http/Controllers/Api/V1/UserTokenController.php b/app/Http/Controllers/Api/V1/UserTokenController.php new file mode 100644 index 0000000..a5ecf81 --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserTokenController.php @@ -0,0 +1,117 @@ + + */ + public function index(Request $request): PaginatedDataCollection + { + $this->requirePersonalAccessToken($request); + + /** @var User $user */ + $user = $request->user(); + + $tokens = $user->accessTokens() + ->orderBy('created_at', 'desc') + ->paginate($this->perPage($request)) + ->through(fn ($token) => AccessTokenData::fromModel($token)); + + return AccessTokenData::collect($tokens, PaginatedDataCollection::class); + } + + public function store(StoreAccessTokenRequest $request): TokenCreatedData + { + $this->requirePersonalAccessToken($request); + + /** @var User $user */ + $user = $request->user(); + + $scopes = $request->validated('scopes') ?? [TokenScope::Composer->value]; + $this->assertCanGrantScopes($request, $scopes); + + return $this->createAccessToken->handle( + organization: null, + user: $user, + name: $request->validated('name'), + expiresAt: $request->validated('expires_at') ? now()->parse($request->validated('expires_at')) : null, + scopes: $scopes, + ); + } + + public function update(UpdateAccessTokenRequest $request, AccessToken $token): AccessTokenData + { + $this->requirePersonalAccessToken($request); + + /** @var User $user */ + $user = $request->user(); + + abort_unless($token->user_uuid === $user->uuid, 404); + + $scopes = $request->validated('scopes'); + if ($scopes !== null) { + $this->assertCanGrantScopes($request, $scopes); + } + + $this->updateAccessToken->handle( + accessToken: $token, + name: $request->validated('name'), + scopes: $scopes, + actor: $user, + ); + + return AccessTokenData::fromModel($token->refresh()); + } + + public function destroy(Request $request, AccessToken $token): Response + { + $this->requirePersonalAccessToken($request); + + /** @var User $user */ + $user = $request->user(); + + abort_unless($token->user_uuid === $user->uuid, 404); + + $token->delete(); + + return response()->noContent(); + } + + /** + * A token may only grant scopes it itself holds (legacy null-scope tokens may grant any). + * + * @param array $scopeValues + */ + private function assertCanGrantScopes(Request $request, array $scopeValues): void + { + $accessToken = $this->accessToken($request); + + foreach ($scopeValues as $value) { + if (! $this->scopeChecker->hasScope($accessToken, TokenScope::from($value))) { + abort(403, "Token cannot grant the '{$value}' scope it does not itself hold."); + } + } + } +} diff --git a/app/Http/Middleware/ApiTokenAuth.php b/app/Http/Middleware/ApiTokenAuth.php new file mode 100644 index 0000000..1a4804c --- /dev/null +++ b/app/Http/Middleware/ApiTokenAuth.php @@ -0,0 +1,86 @@ +accessTokenResolver->fromRequest($request); + + if (! $accessToken || ! $accessToken->isValid()) { + return $this->unauthorized(); + } + + // Establish the acting user so existing policies and Form Requests work: + // a personal access token acts as its user; an organization token acts as + // the organization's owner (always a member with the owner role). + if ($accessToken->user_uuid && $accessToken->user) { + Auth::setUser($accessToken->user); + } elseif ($accessToken->organization_uuid && $accessToken->organization?->owner) { + Auth::setUser($accessToken->organization->owner); + $request->attributes->set('api_token_organization', $accessToken->organization); + } else { + // Token references a missing user/organization (e.g. soft-deleted org). + return $this->unauthorized(); + } + + // An organization-scoped token may only ever act on its own organization, + // regardless of who its owner is a member of elsewhere. + if ($accessToken->organization_uuid && ! $this->organizationMatches($request, $accessToken)) { + return $this->forbidden(); + } + + $accessToken->markAsUsed(); + + $request->merge(['accessToken' => $accessToken]); + + return $next($request); + } + + protected function organizationMatches(Request $request, AccessToken $accessToken): bool + { + $routeOrganization = $request->route('organization'); + + // No organization in the route (e.g. /user, /organizations) — nothing to mismatch. + if ($routeOrganization === null) { + return true; + } + + if ($routeOrganization instanceof Organization) { + return $routeOrganization->uuid === $accessToken->organization_uuid; + } + + $organization = Organization::where('slug', $routeOrganization)->first(); + + return $organization !== null && $organization->uuid === $accessToken->organization_uuid; + } + + protected function unauthorized(): Response + { + return response()->json([ + 'message' => 'Unauthenticated.', + ], 401, [ + 'WWW-Authenticate' => 'Bearer realm="Pricore"', + ]); + } + + protected function forbidden(): Response + { + return response()->json([ + 'message' => 'This token cannot access this organization.', + ], 403); + } +} diff --git a/app/Http/Middleware/ComposerTokenAuth.php b/app/Http/Middleware/ComposerTokenAuth.php index 85a1bfc..7181887 100644 --- a/app/Http/Middleware/ComposerTokenAuth.php +++ b/app/Http/Middleware/ComposerTokenAuth.php @@ -2,6 +2,9 @@ namespace App\Http\Middleware; +use App\Domains\Token\Contracts\Enums\TokenScope; +use App\Domains\Token\Services\AccessTokenResolver; +use App\Domains\Token\Services\TokenScopeChecker; use App\Models\AccessToken; use App\Models\Organization; use Closure; @@ -10,18 +13,21 @@ class ComposerTokenAuth { + public function __construct( + protected AccessTokenResolver $accessTokenResolver, + protected TokenScopeChecker $scopeChecker, + ) {} + public function handle(Request $request, Closure $next): Response { - $token = $this->extractToken($request); + $accessToken = $this->accessTokenResolver->fromRequest($request); - if (! $token) { + if (! $accessToken || ! $accessToken->isValid()) { return $this->unauthorized(); } - $accessToken = $this->findAccessToken($token); - - if (! $accessToken || ! $accessToken->isValid()) { - return $this->unauthorized(); + if (! $this->scopeChecker->hasScope($accessToken, TokenScope::Composer)) { + return $this->forbidden(); } $organization = $request->route('organization'); @@ -42,46 +48,6 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - protected function extractToken(Request $request): ?string - { - $header = $request->header('Authorization'); - - if (! $header) { - return null; - } - - // Check for Bearer token - if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches)) { - return $matches[1]; - } - - // Check for Basic auth (token as password, username is ignored) - if (preg_match('/^Basic\s+(.+)$/i', $header, $matches)) { - $decoded = base64_decode($matches[1], true); - if ($decoded === false) { - return null; - } - - // Basic auth format: username:password - // The token must be in the password field (e.g., "token:YOUR_TOKEN") - $parts = explode(':', $decoded, 2); - - return $parts[1] ?? null; - } - - return null; - } - - protected function findAccessToken(string $token): ?AccessToken - { - $tokenHash = hash('sha256', $token); - - return AccessToken::query() - ->where('token_hash', $tokenHash) - ->with(['organization', 'user']) - ->first(); - } - protected function canAccessOrganization(AccessToken $accessToken, ?Organization $organization): bool { if (! $organization) { @@ -111,4 +77,11 @@ protected function unauthorized(): Response 'WWW-Authenticate' => 'Bearer realm="Pricore"', ]); } + + protected function forbidden(): Response + { + return response()->json([ + 'message' => 'This token does not have Composer registry access.', + ], 403); + } } diff --git a/app/Http/Middleware/RequireTokenScope.php b/app/Http/Middleware/RequireTokenScope.php new file mode 100644 index 0000000..2d6d885 --- /dev/null +++ b/app/Http/Middleware/RequireTokenScope.php @@ -0,0 +1,53 @@ +middleware('scope:write:repositories'). Multiple comma-separated + * scopes are treated as "any of"; chain two scope middlewares for "all of". + */ + public function handle(Request $request, Closure $next, string ...$scopes): Response + { + $accessToken = $request->get('accessToken'); + + if (! $accessToken instanceof AccessToken) { + return $this->unauthorized(); + } + + $required = array_map( + fn (string $scope) => TokenScope::from($scope), + $scopes, + ); + + if (! $this->scopeChecker->hasAny($accessToken, ...$required)) { + return response()->json([ + 'message' => 'Insufficient token scope.', + 'required_scope' => $scopes[0] ?? null, + ], 403); + } + + return $next($request); + } + + protected function unauthorized(): Response + { + return response()->json([ + 'message' => 'Unauthenticated.', + ], 401, [ + 'WWW-Authenticate' => 'Bearer realm="Pricore"', + ]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b3b3d8d..d60cde8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,8 +12,12 @@ use App\Models\User; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use SocialiteProviders\GitLab\GitLabExtendSocialite; @@ -43,5 +47,16 @@ public function boot(): void 'organization_ssh_key' => OrganizationSshKey::class, 'mirror' => Mirror::class, ]); + + RateLimiter::for('api', function (Request $request) { + $accessToken = $request->get('accessToken'); + $key = $accessToken instanceof AccessToken ? $accessToken->uuid : (string) $request->ip(); + + return Limit::perMinute(120)->by($key); + }); + + // Scramble auto-allows API docs in the local environment; elsewhere + // restrict the interactive docs UI to authenticated users. + Gate::define('viewApiDocs', fn (?User $user) => $user !== null); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 327bf0c..86f00be 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,9 +1,11 @@ prefix('api/v1') + ->name('api.v1.') + ->group(base_path('routes/api.php')); + Route::middleware('api') ->group(base_path('routes/composer.php')); @@ -47,6 +54,8 @@ $middleware->alias([ 'composer.token' => ComposerTokenAuth::class, + 'api.token' => ApiTokenAuth::class, + 'scope' => RequireTokenScope::class, 'organization.member' => EnsureOrganizationMembership::class, 'track.organization' => TrackOrganizationAccess::class, ]); diff --git a/composer.json b/composer.json index 302ec8e..f04dcf1 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "aws/aws-sdk-php": "^3.369", "composer/metadata-minifier": "^1.0", "composer/semver": "^3.4", + "dedoc/scramble": "^0.13.27", "inertiajs/inertia-laravel": "^3.0", "laravel/fortify": "^1.30", "laravel/framework": "^13.0", @@ -102,6 +103,9 @@ "ts": [ "@php artisan typescript:transform" ], + "api:docs": [ + "@php artisan scramble:export --path=docs/public/openapi.json" + ], "cloud:link": [ "composer config repositories.pricore-cloud path ../pricore-cloud", "composer require pricorephp/pricore-cloud:@dev --no-interaction" diff --git a/composer.lock b/composer.lock index cb0b243..1a030f2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6e6d4b3a092b3e6e320bd210acfe9a45", + "content-hash": "c54a28c4d51ffe5ad2ef9eb2fcc72f63", "packages": [ { "name": "aws/aws-crt-php", @@ -667,6 +667,86 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "dedoc/scramble", + "version": "v0.13.27", + "source": { + "type": "git", + "url": "https://github.com/dedoc/scramble.git", + "reference": "5b067b1168b4092ee10b320469a09823610f48b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/5b067b1168b4092ee10b320469a09823610f48b1", + "reference": "5b067b1168b4092ee10b320469a09823610f48b1", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "myclabs/deep-copy": "^1.12", + "nikic/php-parser": "^5.0", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "larastan/larastan": "^3.3", + "laravel/pint": "^v1.1.0", + "nunomaduro/collision": "^7.0|^8.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.34|^3.7|^4.4", + "pestphp/pest-plugin-laravel": "^2.3|^3.1|^4.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5|^11.5.3|^12.5.12", + "spatie/laravel-permission": "^6.10|^7.2", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Dedoc\\Scramble\\ScrambleServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Dedoc\\Scramble\\": "src", + "Dedoc\\Scramble\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Lytvynenko", + "email": "litvinenko95@gmail.com", + "role": "Developer" + } + ], + "description": "Automatic generation of API documentation for Laravel applications.", + "homepage": "https://github.com/dedoc/scramble", + "keywords": [ + "documentation", + "laravel", + "openapi" + ], + "support": { + "issues": "https://github.com/dedoc/scramble/issues", + "source": "https://github.com/dedoc/scramble/tree/v0.13.27" + }, + "funding": [ + { + "url": "https://github.com/romalytvynenko", + "type": "github" + } + ], + "time": "2026-06-12T12:41:44+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -3636,6 +3716,66 @@ }, "time": "2024-09-04T18:46:31+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, { "name": "nesbot/carbon", "version": "3.11.4", @@ -11733,66 +11873,6 @@ }, "time": "2024-05-16T03:13:13+00:00" }, - { - "name": "myclabs/deep-copy", - "version": "1.13.4", - "source": { - "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" - }, - "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" - }, - "type": "library", - "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2025-08-01T08:46:24+00:00" - }, { "name": "nunomaduro/collision", "version": "v8.9.4", diff --git a/config/scramble.php b/config/scramble.php new file mode 100644 index 0000000..1389acd --- /dev/null +++ b/config/scramble.php @@ -0,0 +1,181 @@ + [ + * 'include' => 'api', + * 'exclude' => ['api/internal'], + * ], + * + * Without *, patterns match path segments (api matches api and api/users, not apiary). + * With *, Str::is is used (e.g. api/v*). + * + * One static include → default server is /{include} and paths are stripped (/users). + * Multiple includes or wildcards → server defaults to / and paths stay full (/api/users). + * Override with `servers`, or use Scramble::registerApi() for separate bases. + */ + 'api_path' => 'api/v1', + + /* + * Your API domain. By default, app domain is used. This is also a part of the default API routes + * matcher, so when implementing your own, make sure you use this config if needed. + */ + 'api_domain' => null, + + /* + * The path where your OpenAPI specification will be exported. + */ + 'export_path' => 'docs/public/openapi.json', + + /* + * Cache configuration for the generated OpenAPI document. + * + * Use `scramble:cache` to warm the cache and `scramble:clear` to invalidate it. + */ + 'cache' => [ + 'key' => 'scramble.openapi', + 'store' => 'file', + ], + + 'info' => [ + /* + * API version. + */ + 'version' => env('API_VERSION', 'v1'), + + /* + * Description rendered on the home page of the API documentation (`/docs/api`). + */ + 'description' => 'REST API for managing a Pricore registry: organizations, repositories, packages, members, mirrors, and access tokens. Authenticate with a personal or organization access token sent as `Authorization: Bearer `.', + ], + + 'ui' => [ + 'title' => 'Pricore API', + ], + + 'renderer' => 'elements', + + 'renderers' => [ + /* + * Stoplight Elements config options: https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options + */ + 'elements' => [ + 'view' => 'scramble::docs', + 'theme' => 'light', + 'hideTryIt' => false, + 'hideSchemas' => false, + 'logo' => '', + 'tryItCredentialsPolicy' => 'include', + 'layout' => 'responsive', + 'router' => 'hash', + ], + /* + * Scalar API reference config options: https://scalar.com/products/api-references/configuration + */ + 'scalar' => [ + 'view' => 'scramble::scalar', + 'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference', + 'theme' => 'laravel', + 'proxyUrl' => 'https://proxy.scalar.com', + 'darkMode' => false, + 'showDeveloperTools' => 'never', + 'agent' => ['disabled' => true], + 'credentials' => 'include', + ], + ], + + /* + * The list of servers of the API. By default, when `null`, server URL will be created from + * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you + * will need to specify the local server URL manually (if needed). + * + * Example of non-default config (final URLs are generated using Laravel `url` helper): + * + * ```php + * 'servers' => [ + * 'Live' => 'api', + * 'Prod' => 'https://scramble.dedoc.co/api', + * ], + * ``` + */ + 'servers' => null, + + /** + * Determines how Scramble stores the descriptions of enum cases. + * Available options: + * - 'description' – Case descriptions are stored as the enum schema's description using table formatting. + * - 'extension' – Case descriptions are stored in the `x-enumDescriptions` enum schema extension. + * + * @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions + * - false - Case descriptions are ignored. + */ + 'enum_cases_description_strategy' => 'description', + + /** + * Determines how Scramble stores the names of enum cases. + * Available options: + * - 'names' – Case names are stored in the `x-enumNames` enum schema extension. + * - 'varnames' - Case names are stored in the `x-enum-varnames` enum schema extension. + * - false - Case names are not stored. + */ + 'enum_cases_names_strategy' => false, + + /** + * When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated + * OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until + * OpenAPI 3.2 is released and query string structure can be described properly. + * + * For example, this nested validation rule describes the object with `bar` property: + * `['foo.bar' => ['required', 'int']]`. + * + * When `flatten_deep_query_parameters` is `true`, Scramble will document the parameter like so: + * `{"name":"foo[bar]", "schema":{"type":"int"}, "required":true}`. + * + * When `flatten_deep_query_parameters` is `false`, Scramble will document the parameter like so: + * `{"name":"foo", "schema": {"type":"object", "properties":{"bar":{"type": "int"}}, "required": ["bar"]}, "required":true}`. + */ + 'flatten_deep_query_parameters' => true, + + 'middleware' => [ + 'web', + RestrictedDocsAccess::class, + ], + + 'extensions' => [], + + /* + * Automatically document API security (OpenAPI `security` / `securitySchemes`) based on route + * middleware. + * + * Disabled by default. Uncomment the line below to enable `MiddlewareAuthSecurityStrategy`. + * When at least one documented route uses middleware matching the configured patterns (by default + * `auth` and `auth:*`), bearer auth is applied globally. Routes without matching middleware are + * marked as public (`security: []`). + * + * Set to `null` explicitly to disable. If you already configure security manually via + * `afterOpenApiGenerated` / `extendOpenApi`, keep this disabled to avoid duplicate schemes. + * + * Customize with a class-string or [class, options]: + * + * 'security_strategy' => [ + * \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, + * [ + * 'middleware' => ['auth', 'auth:*'], + * 'scheme' => \Dedoc\Scramble\Support\Generator\SecurityScheme::http('bearer'), + * ], + * ], + */ + 'security_strategy' => [ + MiddlewareAuthSecurityStrategy::class, + [ + 'middleware' => ['api.token'], + 'scheme' => SecurityScheme::http('bearer'), + ], + ], +]; diff --git a/database/factories/AccessTokenFactory.php b/database/factories/AccessTokenFactory.php index e7e21b3..e8bfebd 100644 --- a/database/factories/AccessTokenFactory.php +++ b/database/factories/AccessTokenFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Domains\Token\Contracts\Enums\TokenScope; use App\Models\AccessToken; use App\Models\Organization; use App\Models\User; @@ -33,7 +34,7 @@ public function definition(): array 'user_uuid' => null, 'name' => fake()->words(2, true).' Token', 'token_hash' => hash('sha256', Str::random(64)), - 'scopes' => fake()->optional(0.6)->randomElements(['read', 'write', 'admin'], fake()->numberBetween(1, 3)), + 'scopes' => null, 'last_used_at' => fake()->optional(0.7)->dateTimeBetween('-30 days', 'now'), 'expires_at' => fake()->optional(0.5)->dateTimeBetween('now', '+2 years'), ]; @@ -84,15 +85,26 @@ public function neverExpires(): static /** * Indicate that the token has specific scopes. * - * @param array $scopes + * @param array $scopes */ public function withScopes(array $scopes): static { return $this->state(fn (array $attributes) => [ - 'scopes' => $scopes, + 'scopes' => array_map( + fn (TokenScope|string $scope) => $scope instanceof TokenScope ? $scope->value : $scope, + $scopes, + ), ]); } + /** + * Indicate that the token is limited to Composer registry access. + */ + public function composerOnly(): static + { + return $this->withScopes([TokenScope::Composer]); + } + /** * Indicate that the token was recently used. */ diff --git a/docs/api/index.md b/docs/api/index.md index 2f2cd67..3d8ff3b 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,10 +1,79 @@ # API Reference -Pricore provides a Composer-compatible API for package distribution and webhook endpoints for automatic syncing. +Pricore exposes three HTTP interfaces: -## Authentication +- **Management API (REST)** — a JSON API under `/api/v1` for managing organizations, repositories, packages, members, mirrors, and access tokens programmatically. +- **Composer Repository API** — the Composer v2-compatible endpoints used by `composer` to resolve and download packages. +- **Webhooks** — endpoints your Git provider calls to trigger automatic syncing. -The Composer API requires authentication via access token. Two methods are supported: +## Management API (REST) + +The Management API lets you automate Pricore — for example, an installer can mint a Composer token and connect a repository without anyone touching the UI. + +- **Base URL:** `https://pricore.yourcompany.com/api/v1` +- **Format:** JSON. Send `Accept: application/json`. +- **Interactive docs:** browse and try endpoints at `/docs/api`. The full OpenAPI 3.1 specification is published at [`/openapi.json`](/openapi.json). +- **Rate limit:** 120 requests/minute per token. + +### Authentication + +Send an access token as a Bearer token: + +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Accept: application/json" \ + https://pricore.yourcompany.com/api/v1/user +``` + +Two kinds of token work: + +- **Personal access tokens** act as your user and can reach every organization you belong to. Create them under **Settings → Access Tokens**, or via `POST /api/v1/user/tokens`. +- **Organization tokens** are scoped to a single organization. Create them under the organization's token settings, or via `POST /api/v1/organizations/{organization}/tokens`. + +### Scopes + +Every token carries a set of scopes that limit what it can do. Requests are checked against both the token's scopes **and** your role in the organization, so a token can never exceed its owner's permissions. Tokens created before scopes existed retain full access. + +| Scope | Grants | +|-------|--------| +| `composer` | Composer registry access (package resolution & downloads) | +| `read:organizations` / `write:organizations` / `delete:organizations` | View / create & update / delete organizations | +| `read:repositories` / `write:repositories` / `delete:repositories` | View / add & sync / delete repositories | +| `read:packages` / `write:packages` / `delete:packages` | View / manage / delete packages & versions | +| `read:members` / `write:members` | View / manage members & invitations | +| `read:mirrors` / `write:mirrors` / `delete:mirrors` | View / add & sync / delete mirrors | +| `read:tokens` / `write:tokens` | View / create & revoke access tokens | + +### Pagination + +List endpoints return a paginated envelope. Use `?page=` and `?per_page=` (max 100) to navigate: + +```json +{ + "data": [ /* … */ ], + "links": { "first": "…", "last": "…", "prev": null, "next": "…" }, + "meta": { "current_page": 1, "per_page": 25, "total": 42 } +} +``` + +### Resources + +| Resource | Endpoints | +|----------|-----------| +| User | `GET /user`, `GET /user/organizations`, `GET\|POST\|DELETE /user/tokens` | +| Organizations | `GET\|POST /organizations`, `GET\|PATCH\|DELETE /organizations/{organization}` | +| Repositories | `GET\|POST /…/repositories`, `POST /…/repositories/bulk`, `GET\|POST(sync)\|DELETE /…/repositories/{uuid}` | +| Packages | `GET /…/packages`, `GET\|DELETE /…/packages/{uuid}`, `GET /…/packages/{uuid}/versions`, `DELETE /…/packages/{uuid}/versions/{uuid}` | +| Members | `GET\|POST /…/members`, `PATCH\|DELETE /…/members/{uuid}` | +| Invitations | `GET /…/invitations`, `POST /…/invitations/{uuid}/resend`, `DELETE /…/invitations/{uuid}` | +| Mirrors | `GET\|POST /…/mirrors`, `GET\|POST(sync)\|DELETE /…/mirrors/{uuid}` | +| Tokens | `GET\|POST /…/tokens`, `DELETE /…/tokens/{uuid}` | + +See the [OpenAPI specification](/openapi.json) for request/response schemas of every endpoint. + +## Composer API Authentication + +The Composer API requires authentication via an access token that carries the `composer` scope (tokens created before scopes existed retain access). Two methods are supported: ### HTTP Basic Auth diff --git a/docs/public/openapi.json b/docs/public/openapi.json new file mode 100644 index 0000000..ae0ee9c --- /dev/null +++ b/docs/public/openapi.json @@ -0,0 +1,1990 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Pricore API", + "version": "v1", + "description": "REST API for managing a Pricore registry: organizations, repositories, packages, members, mirrors, and access tokens. Authenticate with a personal or organization access token sent as `Authorization: Bearer `." + }, + "servers": [ + { + "url": "http://pricore.test/api/v1" + } + ], + "security": [ + { + "http": [] + } + ], + "paths": { + "/organizations/{organization}/invitations": { + "get": { + "operationId": "v1.invitations.index", + "tags": [ + "Invitation" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/invitations/{invitation}/resend": { + "post": { + "operationId": "v1.invitations.resend", + "tags": [ + "Invitation" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "invitation", + "in": "path", + "required": true, + "description": "The invitation UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`OrganizationInvitationData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationData" + } + } + } + }, + "422": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "example": "This invitation has already been accepted." + } + }, + "required": [ + "message" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/invitations/{invitation}": { + "delete": { + "operationId": "v1.invitations.destroy", + "tags": [ + "Invitation" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "invitation", + "in": "path", + "required": true, + "description": "The invitation UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/members": { + "get": { + "operationId": "v1.members.index", + "tags": [ + "Member" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "post": { + "operationId": "v1.members.store", + "tags": [ + "Member" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddMemberRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`OrganizationInvitationData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationInvitationData" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/members/{member}": { + "patch": { + "operationId": "v1.members.update", + "tags": [ + "Member" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "member", + "in": "path", + "required": true, + "description": "The member UUID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMemberRoleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`OrganizationMemberData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMemberData" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "delete": { + "operationId": "v1.members.destroy", + "tags": [ + "Member" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "member", + "in": "path", + "required": true, + "description": "The member UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "422": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "example": "Cannot remove the organization owner." + } + }, + "required": [ + "message" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/mirrors": { + "get": { + "operationId": "v1.mirrors.index", + "tags": [ + "Mirror" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "post": { + "operationId": "v1.mirrors.store", + "tags": [ + "Mirror" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreMirrorRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`MirrorData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MirrorData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, + "/organizations/{organization}/mirrors/{mirror}": { + "get": { + "operationId": "v1.mirrors.show", + "tags": [ + "Mirror" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "mirror", + "in": "path", + "required": true, + "description": "The mirror UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`MirrorData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MirrorData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "delete": { + "operationId": "v1.mirrors.destroy", + "tags": [ + "Mirror" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "mirror", + "in": "path", + "required": true, + "description": "The mirror UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/mirrors/{mirror}/sync": { + "post": { + "operationId": "v1.mirrors.sync", + "tags": [ + "Mirror" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "mirror", + "in": "path", + "required": true, + "description": "The mirror UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`MirrorData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MirrorData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations": { + "get": { + "operationId": "v1.organizations.index", + "summary": "List organizations accessible to the authenticated token", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + } + } + }, + "post": { + "operationId": "v1.organizations.store", + "summary": "Create a new organization owned by the authenticated user", + "tags": [ + "Organization" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreOrganizationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`OrganizationData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationData" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, + "/organizations/{organization}": { + "get": { + "operationId": "v1.organizations.show", + "tags": [ + "Organization" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`OrganizationData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "patch": { + "operationId": "v1.organizations.update", + "tags": [ + "Organization" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganizationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`OrganizationData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationData" + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + } + } + }, + "delete": { + "operationId": "v1.organizations.destroy", + "tags": [ + "Organization" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/packages": { + "get": { + "operationId": "v1.packages.index", + "tags": [ + "Package" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/packages/{package}": { + "get": { + "operationId": "v1.packages.show", + "tags": [ + "Package" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "package", + "in": "path", + "required": true, + "description": "The package UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`PackageData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackageData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "delete": { + "operationId": "v1.packages.destroy", + "tags": [ + "Package" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "package", + "in": "path", + "required": true, + "description": "The package UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/packages/{package}/versions": { + "get": { + "operationId": "v1.packages.versions.index", + "tags": [ + "PackageVersion" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "package", + "in": "path", + "required": true, + "description": "The package UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/packages/{package}/versions/{version}": { + "delete": { + "operationId": "v1.packages.versions.destroy", + "tags": [ + "PackageVersion" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "package", + "in": "path", + "required": true, + "description": "The package UUID", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "The version UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/repositories": { + "get": { + "operationId": "v1.repositories.index", + "tags": [ + "Repository" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "post": { + "operationId": "v1.repositories.store", + "tags": [ + "Repository" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreRepositoryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`RepositoryData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, + "/organizations/{organization}/repositories/bulk": { + "post": { + "operationId": "v1.repositories.bulk", + "tags": [ + "Repository" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkStoreRepositoryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`BulkImportResultData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkImportResultData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, + "/organizations/{organization}/repositories/{repository}": { + "get": { + "operationId": "v1.repositories.show", + "tags": [ + "Repository" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "repository", + "in": "path", + "required": true, + "description": "The repository UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`RepositoryData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "delete": { + "operationId": "v1.repositories.destroy", + "tags": [ + "Repository" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "repository", + "in": "path", + "required": true, + "description": "The repository UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/repositories/{repository}/sync": { + "post": { + "operationId": "v1.repositories.sync", + "tags": [ + "Repository" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "repository", + "in": "path", + "required": true, + "description": "The repository UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`RepositoryData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/organizations/{organization}/tokens": { + "get": { + "operationId": "v1.tokens.index", + "tags": [ + "Token" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + }, + "post": { + "operationId": "v1.tokens.store", + "tags": [ + "Token" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreAccessTokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`TokenCreatedData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenCreatedData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, + "/organizations/{organization}/tokens/{token}": { + "patch": { + "operationId": "v1.tokens.update", + "tags": [ + "Token" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "description": "The token UUID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAccessTokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`AccessTokenData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenData" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + }, + "delete": { + "operationId": "v1.tokens.destroy", + "tags": [ + "Token" + ], + "parameters": [ + { + "name": "organization", + "in": "path", + "required": true, + "description": "The organization slug", + "schema": { + "type": "string" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "description": "The token UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + }, + "/user": { + "get": { + "operationId": "v1.user.show", + "summary": "Get the authenticated user's profile", + "tags": [ + "User" + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "avatar": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "uuid", + "name", + "email", + "avatar" + ] + } + } + } + } + } + } + }, + "/user/organizations": { + "get": { + "operationId": "v1.user.organizations", + "summary": "List the organizations the authenticated user belongs to", + "tags": [ + "User" + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + } + } + } + }, + "/user/tokens": { + "get": { + "operationId": "v1.user.tokens.index", + "tags": [ + "UserToken" + ], + "responses": { + "200": { + "description": "`PaginatedDataCollection`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedDataCollection" + } + } + } + } + } + }, + "post": { + "operationId": "v1.user.tokens.store", + "tags": [ + "UserToken" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreAccessTokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`TokenCreatedData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenCreatedData" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + } + }, + "/user/tokens/{token}": { + "patch": { + "operationId": "v1.user.tokens.update", + "tags": [ + "UserToken" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "description": "The token UUID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAccessTokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`AccessTokenData`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenData" + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + } + } + }, + "delete": { + "operationId": "v1.user.tokens.destroy", + "tags": [ + "UserToken" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "description": "The token UUID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + } + } + } + } + }, + "components": { + "securitySchemes": { + "http": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "AccessTokenData": { + "type": "object", + "additionalProperties": {}, + "title": "AccessTokenData" + }, + "AddMemberRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "role": { + "type": "string", + "enum": [ + "admin", + "member" + ] + } + }, + "required": [ + "email", + "role" + ], + "title": "AddMemberRequest" + }, + "BulkImportResultData": { + "type": "object", + "additionalProperties": {}, + "title": "BulkImportResultData" + }, + "BulkStoreRepositoryRequest": { + "type": "object", + "properties": { + "provider": { + "$ref": "#/components/schemas/GitProvider" + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repo_identifier": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "repo_identifier" + ] + }, + "minItems": 1, + "maxItems": 50 + } + }, + "required": [ + "provider", + "repositories" + ], + "title": "BulkStoreRepositoryRequest" + }, + "GitProvider": { + "type": "string", + "enum": [ + "github", + "gitlab", + "bitbucket", + "git" + ], + "title": "GitProvider" + }, + "MirrorAuthType": { + "type": "string", + "enum": [ + "none", + "basic", + "bearer" + ], + "title": "MirrorAuthType" + }, + "MirrorData": { + "type": "object", + "additionalProperties": {}, + "title": "MirrorData" + }, + "OrganizationData": { + "type": "object", + "additionalProperties": {}, + "title": "OrganizationData" + }, + "OrganizationInvitationData": { + "type": "object", + "additionalProperties": {}, + "title": "OrganizationInvitationData" + }, + "OrganizationMemberData": { + "type": "object", + "additionalProperties": {}, + "title": "OrganizationMemberData" + }, + "PackageData": { + "type": "object", + "additionalProperties": {}, + "title": "PackageData" + }, + "PaginatedDataCollection": { + "type": "object", + "additionalProperties": {}, + "title": "PaginatedDataCollection" + }, + "RepositoryData": { + "type": "object", + "additionalProperties": {}, + "title": "RepositoryData" + }, + "StoreAccessTokenRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "scopes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/TokenScope" + } + } + }, + "required": [ + "name" + ], + "title": "StoreAccessTokenRequest" + }, + "StoreMirrorRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "auth_type": { + "$ref": "#/components/schemas/MirrorAuthType" + }, + "username": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + }, + "password": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + }, + "token": { + "type": [ + "string", + "null" + ], + "maxLength": 1024 + }, + "mirror_dist": { + "type": "boolean" + } + }, + "required": [ + "name", + "url", + "auth_type" + ], + "title": "StoreMirrorRequest" + }, + "StoreOrganizationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + } + }, + "required": [ + "name" + ], + "title": "StoreOrganizationRequest" + }, + "StoreRepositoryRequest": { + "type": "object", + "properties": { + "name": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + }, + "provider": { + "$ref": "#/components/schemas/GitProvider" + }, + "repo_identifier": { + "type": "string", + "maxLength": 500 + }, + "default_branch": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + }, + "ssh_key_uuid": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + }, + "required": [ + "provider", + "repo_identifier" + ], + "title": "StoreRepositoryRequest" + }, + "TokenCreatedData": { + "type": "object", + "additionalProperties": {}, + "title": "TokenCreatedData" + }, + "TokenScope": { + "type": "string", + "enum": [ + "composer", + "read:organizations", + "write:organizations", + "delete:organizations", + "read:repositories", + "write:repositories", + "delete:repositories", + "read:packages", + "write:packages", + "delete:packages", + "read:members", + "write:members", + "read:mirrors", + "write:mirrors", + "delete:mirrors", + "read:tokens", + "write:tokens" + ], + "title": "TokenScope" + }, + "UpdateAccessTokenRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "scopes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/TokenScope" + } + } + }, + "required": [ + "name" + ], + "title": "UpdateAccessTokenRequest" + }, + "UpdateMemberRoleRequest": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "admin", + "member" + ] + } + }, + "required": [ + "role" + ], + "title": "UpdateMemberRoleRequest" + }, + "UpdateOrganizationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + } + }, + "required": [ + "name" + ], + "title": "UpdateOrganizationRequest" + } + }, + "responses": { + "ValidationException": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Errors overview." + }, + "errors": { + "type": "object", + "description": "A detailed description of each field that failed validation.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "message", + "errors" + ] + } + } + } + }, + "ModelNotFoundException": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview." + } + }, + "required": [ + "message" + ] + } + } + } + }, + "AuthorizationException": { + "description": "Authorization error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview." + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } +} \ No newline at end of file diff --git a/resources/js/components/create-token-dialog.tsx b/resources/js/components/create-token-dialog.tsx index e177fe8..6d766c4 100644 --- a/resources/js/components/create-token-dialog.tsx +++ b/resources/js/components/create-token-dialog.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -16,7 +17,10 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { API_SCOPE_GROUPS } from '@/lib/token-scopes'; import { Form } from '@inertiajs/react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useState } from 'react'; interface CreateTokenDialogProps { storeUrl: string; @@ -31,15 +35,17 @@ export default function CreateTokenDialog({ isOpen, onClose, }: CreateTokenDialogProps) { + const [showApiScopes, setShowApiScopes] = useState(false); + return ( - + Create Access Token {description} -
+ {({ processing, errors }) => ( <>
@@ -51,7 +57,7 @@ export default function CreateTokenDialog({ id="name" name="name" required - placeholder="My API Token" + placeholder="e.g. Production server" autoFocus /> {errors.name && ( @@ -91,6 +97,90 @@ export default function CreateTokenDialog({ )}
+ {/* Every token can install packages with Composer. */} + + +
+ + + {showApiScopes && ( +
+

+ Pick the permissions this token + needs. It can never exceed your own + access. +

+ {API_SCOPE_GROUPS.map((group) => ( +
+ + {group.label} + +
+ {group.scopes.map( + (scope) => ( +
+ + +
+ ), + )} +
+
+ ))} + {errors.scopes && ( +

+ {errors.scopes} +

+ )} +
+ )} +
+ + + {showApiScopes && ( +
+

+ Pick the permissions this token + needs. It can never exceed your own + access. +

+ {API_SCOPE_GROUPS.map((group) => ( +
+ + {group.label} + +
+ {group.scopes.map( + (scope) => ( +
+ + +
+ ), + )} +
+
+ ))} + {errors.scopes && ( +

+ {errors.scopes} +

+ )} +
+ )} + + + + + + + + )} + +
+
+ ); +} diff --git a/resources/js/components/token-created-dialog.tsx b/resources/js/components/token-created-dialog.tsx index dc4ed47..3521844 100644 --- a/resources/js/components/token-created-dialog.tsx +++ b/resources/js/components/token-created-dialog.tsx @@ -15,6 +15,7 @@ interface TokenCreatedDialogProps { token: string; name: string; expiresAt: string | null; + scopes: string[]; isOpen: boolean; onClose: () => void; } @@ -23,19 +24,16 @@ export default function TokenCreatedDialog({ token, name, expiresAt, + scopes, isOpen, onClose, }: TokenCreatedDialogProps) { - const [copiedToken, setCopiedToken] = useState(false); - const [copiedComposer, setCopiedComposer] = useState(false); + const [copied, setCopied] = useState(null); const inputRef = useRef(null); - const copyToClipboard = async ( - text: string, - type: 'token' | 'composer', - ) => { + const copyToClipboard = async (text: string, key: string) => { try { - if (type === 'token' && inputRef.current) { + if (key === 'token' && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } @@ -55,10 +53,8 @@ export default function TokenCreatedDialog({ textArea.remove(); } - const setCopied = - type === 'token' ? setCopiedToken : setCopiedComposer; - setCopied(true); - setTimeout(() => setCopied(false), 2000); + setCopied(key); + setTimeout(() => setCopied(null), 2000); } catch (error) { console.error('Failed to copy:', error); } @@ -66,9 +62,18 @@ export default function TokenCreatedDialog({ const domain = window.location.host; + // A legacy token with no recorded scopes can do everything. + const grantsEverything = scopes.length === 0; + const hasComposer = grantsEverything || scopes.includes('composer'); + const hasApi = + grantsEverything || scopes.some((scope) => scope !== 'composer'); + + const composerCommand = `composer config --global --auth http-basic.${domain} token ${token}`; + const apiCommand = `curl -H "Authorization: Bearer ${token}" \\\n -H "Accept: application/json" \\\n https://${domain}/api/v1/user`; + return ( !open && onClose()}> - + Access Token Created @@ -110,7 +115,7 @@ export default function TokenCreatedDialog({ onClick={() => copyToClipboard(token, 'token')} className="shrink-0" > - {copiedToken ? ( + {copied === 'token' ? ( ) : ( @@ -119,38 +124,79 @@ export default function TokenCreatedDialog({ -
-

- Configure Composer -

-

- Use this token to authenticate with Composer: -

-
- - composer config --global --auth http-basic. - {domain} token {token} - - + {hasComposer && ( +
+

+ Configure Composer +

+

+ Use this token to authenticate with Composer: +

+
+ + {composerCommand} + + +
-
+ )} + + {hasApi && ( +
+

+ Use the API +

+

+ Send the token as a Bearer header. See the{' '} + + API reference + + . +

+
+ + {apiCommand} + + +
+
+ )}
diff --git a/resources/js/components/token-list.tsx b/resources/js/components/token-list.tsx index 060f846..d7c1527 100644 --- a/resources/js/components/token-list.tsx +++ b/resources/js/components/token-list.tsx @@ -1,17 +1,22 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { Trash2 } from 'lucide-react'; +import { Pencil, Trash2 } from 'lucide-react'; import { DateTime } from 'luxon'; type AccessTokenData = App.Domains.Token.Contracts.Data.AccessTokenData; interface TokenListProps { tokens: AccessTokenData[]; + onEdit: (token: AccessTokenData) => void; onRevoke: (uuid: string, name: string) => void; } -export default function TokenList({ tokens, onRevoke }: TokenListProps) { +export default function TokenList({ + tokens, + onEdit, + onRevoke, +}: TokenListProps) { if (tokens.length === 0) { return (
@@ -35,6 +40,7 @@ export default function TokenList({ tokens, onRevoke }: TokenListProps) { Expired )}
+
Last used:{' '} @@ -60,14 +66,24 @@ export default function TokenList({ tokens, onRevoke }: TokenListProps) {
- +
+ + +
))} @@ -79,3 +95,37 @@ function isTokenExpired(expiresAt: string | null): boolean { if (!expiresAt) return false; return new Date(expiresAt) < new Date(); } + +// Composer access is the baseline for every token, so only surface the +// additional API permissions (if any) to keep the list uncluttered. +function ScopeBadges({ scopes }: { scopes: string[] }) { + if (scopes.length === 0) { + return ( +
+ + Full access + +
+ ); + } + + const apiScopes = scopes.filter((scope) => scope !== 'composer'); + + if (apiScopes.length === 0) { + return null; + } + + return ( +
+ {apiScopes.map((scope) => ( + + {scope} + + ))} +
+ ); +} diff --git a/resources/js/layouts/organization-settings-layout.tsx b/resources/js/layouts/organization-settings-layout.tsx index 850a5e5..a662d06 100644 --- a/resources/js/layouts/organization-settings-layout.tsx +++ b/resources/js/layouts/organization-settings-layout.tsx @@ -56,7 +56,7 @@ function SettingsContent({ children }: PropsWithChildren) { label: 'Registry', items: [ { - title: 'Composer Tokens', + title: 'Access Tokens', href: `${base}/tokens`, icon: Key, }, @@ -177,7 +177,7 @@ function SettingsContent({ children }: PropsWithChildren) { const settingsPageTitles: Record = { general: 'General', members: 'Members', - tokens: 'Composer Tokens', + tokens: 'Access Tokens', 'ssh-keys': 'SSH Keys', mirrors: 'Registry Mirrors', security: 'Security', diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 682a3c2..5d7a179 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -47,7 +47,7 @@ const sidebarNavItems: NavItem[] = [ icon: GitBranch, }, { - title: 'Tokens', + title: 'Access Tokens', href: tokensIndex(), icon: Key, }, diff --git a/resources/js/lib/token-scopes.ts b/resources/js/lib/token-scopes.ts new file mode 100644 index 0000000..8a8896e --- /dev/null +++ b/resources/js/lib/token-scopes.ts @@ -0,0 +1,57 @@ +type TokenScope = App.Domains.Token.Contracts.Enums.TokenScope; + +export interface ScopeGroup { + label: string; + scopes: { value: TokenScope; label: string }[]; +} + +// Composer access is granted to every token implicitly; these are the +// additional, opt-in permissions for the management API. +export const API_SCOPE_GROUPS: ScopeGroup[] = [ + { + label: 'Organizations', + scopes: [ + { value: 'read:organizations', label: 'Read' }, + { value: 'write:organizations', label: 'Write' }, + { value: 'delete:organizations', label: 'Delete' }, + ], + }, + { + label: 'Repositories', + scopes: [ + { value: 'read:repositories', label: 'Read' }, + { value: 'write:repositories', label: 'Write' }, + { value: 'delete:repositories', label: 'Delete' }, + ], + }, + { + label: 'Packages', + scopes: [ + { value: 'read:packages', label: 'Read' }, + { value: 'write:packages', label: 'Write' }, + { value: 'delete:packages', label: 'Delete' }, + ], + }, + { + label: 'Members', + scopes: [ + { value: 'read:members', label: 'Read' }, + { value: 'write:members', label: 'Write' }, + ], + }, + { + label: 'Mirrors', + scopes: [ + { value: 'read:mirrors', label: 'Read' }, + { value: 'write:mirrors', label: 'Write' }, + { value: 'delete:mirrors', label: 'Delete' }, + ], + }, + { + label: 'Tokens', + scopes: [ + { value: 'read:tokens', label: 'Read' }, + { value: 'write:tokens', label: 'Write' }, + ], + }, +]; diff --git a/resources/js/pages/organizations/settings/tokens.tsx b/resources/js/pages/organizations/settings/tokens.tsx index 7cd1494..0bff0d2 100644 --- a/resources/js/pages/organizations/settings/tokens.tsx +++ b/resources/js/pages/organizations/settings/tokens.tsx @@ -1,9 +1,11 @@ import { destroy, store, + update, } from '@/actions/App/Domains/Token/Http/Controllers/TokenController'; import { CopyButton } from '@/components/copy-button'; import CreateTokenDialog from '@/components/create-token-dialog'; +import EditTokenDialog from '@/components/edit-token-dialog'; import InfoBox from '@/components/info-box'; import RevokeTokenDialog from '@/components/revoke-token-dialog'; import TokenCreatedDialog from '@/components/token-created-dialog'; @@ -31,6 +33,10 @@ export default function Tokens({ }: TokensPageProps) { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [revokeDialogOpen, setRevokeDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingToken, setEditingToken] = useState( + null, + ); const [tokenCreatedDialogOpen, setTokenCreatedDialogOpen] = useState(!!tokenCreated); const [selectedToken, setSelectedToken] = useState<{ @@ -68,15 +74,21 @@ export default function Tokens({ setRevokeDialogOpen(true); }; + const handleEdit = (token: AccessTokenData) => { + setEditingToken(token); + setEditDialogOpen(true); + }; + const composerRepoCommand = `composer config repositories.${organization.slug} composer ${organization.composerRepositoryUrl}`; return (
-

Composer Tokens

+

Access Tokens

- Manage access tokens for Composer authentication + Shared tokens for this organization's packages and + automation

- +
setCreateDialogOpen(false)} /> + {editingToken && ( + { + setEditDialogOpen(false); + setEditingToken(null); + }} + /> + )} + {selectedToken && ( setTokenCreatedDialogOpen(false)} /> diff --git a/resources/js/pages/settings/tokens.tsx b/resources/js/pages/settings/tokens.tsx index 5a95b57..db6a6fa 100644 --- a/resources/js/pages/settings/tokens.tsx +++ b/resources/js/pages/settings/tokens.tsx @@ -1,8 +1,10 @@ import { destroy, store, + update, } from '@/actions/App/Domains/Token/Http/Controllers/UserTokenController'; import CreateTokenDialog from '@/components/create-token-dialog'; +import EditTokenDialog from '@/components/edit-token-dialog'; import InfoBox from '@/components/info-box'; import RevokeTokenDialog from '@/components/revoke-token-dialog'; import TokenCreatedDialog from '@/components/token-created-dialog'; @@ -30,7 +32,7 @@ const breadcrumbs: BreadcrumbItem[] = [ href: editProfile().url, }, { - title: 'Tokens', + title: 'Access Tokens', href: tokensIndex().url, }, ]; @@ -38,6 +40,10 @@ const breadcrumbs: BreadcrumbItem[] = [ export default function Tokens({ tokens, tokenCreated }: TokensPageProps) { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [revokeDialogOpen, setRevokeDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingToken, setEditingToken] = useState( + null, + ); const [tokenCreatedDialogOpen, setTokenCreatedDialogOpen] = useState(!!tokenCreated); const [selectedToken, setSelectedToken] = useState<{ @@ -75,6 +81,11 @@ export default function Tokens({ tokens, tokenCreated }: TokensPageProps) { setRevokeDialogOpen(true); }; + const handleEdit = (token: AccessTokenData) => { + setEditingToken(token); + setEditDialogOpen(true); + }; + return ( @@ -82,11 +93,11 @@ export default function Tokens({ tokens, tokenCreated }: TokensPageProps) {

- Personal Tokens + Personal Access Tokens

- Manage personal access tokens for Composer - authentication + Install packages with Composer across every + organization you belong to

- +
setCreateDialogOpen(false)} /> + {editingToken && ( + { + setEditDialogOpen(false); + setEditingToken(null); + }} + /> + )} + {selectedToken && ( setTokenCreatedDialogOpen(false)} /> diff --git a/resources/types/generated.d.ts b/resources/types/generated.d.ts index 4a52994..ff78c61 100644 --- a/resources/types/generated.d.ts +++ b/resources/types/generated.d.ts @@ -14,7 +14,7 @@ createdAt: string | null; }; } declare namespace App.Domains.Activity.Contracts.Enums { -export type ActivityType = 'repository.added' | 'repository.removed' | 'repository.synced' | 'repository.sync_failed' | 'package.created' | 'package.removed' | 'member.added' | 'member.removed' | 'member.role_changed' | 'invitation.sent' | 'token.created' | 'token.revoked' | 'ssh_key.generated' | 'ssh_key.deleted' | 'mirror.added' | 'mirror.removed' | 'mirror.synced' | 'mirror.sync_failed' | 'security.vulnerabilities_detected'; +export type ActivityType = 'repository.added' | 'repository.removed' | 'repository.synced' | 'repository.sync_failed' | 'package.created' | 'package.removed' | 'member.added' | 'member.removed' | 'member.role_changed' | 'invitation.sent' | 'token.created' | 'token.updated' | 'token.revoked' | 'ssh_key.generated' | 'ssh_key.deleted' | 'mirror.added' | 'mirror.removed' | 'mirror.synced' | 'mirror.sync_failed' | 'security.vulnerabilities_detected'; } declare namespace App.Domains.Auth.Contracts.Enums { export type GitHubOAuthIntent = 'login' | 'connect'; @@ -319,14 +319,19 @@ name: string; lastUsedAt: string | null; expiresAt: string | null; createdAt: string; +scopes: Array; }; export type TokenCreatedData = { plainToken: string; name: string; expiresAt: string | null; organizationUuid: string | null; +scopes: Array; }; } +declare namespace App.Domains.Token.Contracts.Enums { +export type TokenScope = 'composer' | 'read:organizations' | 'write:organizations' | 'delete:organizations' | 'read:repositories' | 'write:repositories' | 'delete:repositories' | 'read:packages' | 'write:packages' | 'delete:packages' | 'read:members' | 'write:members' | 'read:mirrors' | 'write:mirrors' | 'delete:mirrors' | 'read:tokens' | 'write:tokens'; +} declare namespace App.Http.Data { export type AuthData = { user: App.Http.Data.UserData | null; diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..540bfbd --- /dev/null +++ b/routes/api.php @@ -0,0 +1,113 @@ +group(function () { + // Authenticated user (personal access tokens only) + Route::get('user', [UserController::class, 'show'])->name('user.show'); + Route::get('user/organizations', [UserController::class, 'organizations']) + ->middleware('scope:read:organizations')->name('user.organizations'); + + Route::get('user/tokens', [UserTokenController::class, 'index']) + ->middleware('scope:read:tokens')->name('user.tokens.index'); + Route::post('user/tokens', [UserTokenController::class, 'store']) + ->middleware('scope:write:tokens')->name('user.tokens.store'); + Route::patch('user/tokens/{token}', [UserTokenController::class, 'update']) + ->middleware('scope:write:tokens')->name('user.tokens.update'); + Route::delete('user/tokens/{token}', [UserTokenController::class, 'destroy']) + ->middleware('scope:write:tokens')->name('user.tokens.destroy'); + + // Organizations (top level) + Route::get('organizations', [OrganizationController::class, 'index']) + ->middleware('scope:read:organizations')->name('organizations.index'); + Route::post('organizations', [OrganizationController::class, 'store']) + ->middleware('scope:write:organizations')->name('organizations.store'); + + // Organization-scoped resources + Route::prefix('organizations/{organization:slug}') + ->middleware('organization.member') + ->group(function () { + Route::get('/', [OrganizationController::class, 'show']) + ->middleware('scope:read:organizations')->name('organizations.show'); + Route::patch('/', [OrganizationController::class, 'update']) + ->middleware('scope:write:organizations')->name('organizations.update'); + Route::delete('/', [OrganizationController::class, 'destroy']) + ->middleware('scope:delete:organizations')->name('organizations.destroy'); + + // Repositories + Route::get('repositories', [RepositoryController::class, 'index']) + ->middleware('scope:read:repositories')->name('repositories.index'); + Route::post('repositories', [RepositoryController::class, 'store']) + ->middleware('scope:write:repositories')->name('repositories.store'); + Route::post('repositories/bulk', [RepositoryController::class, 'bulkStore']) + ->middleware('scope:write:repositories')->name('repositories.bulk'); + Route::get('repositories/{repository:uuid}', [RepositoryController::class, 'show']) + ->middleware('scope:read:repositories')->name('repositories.show'); + Route::post('repositories/{repository:uuid}/sync', [RepositoryController::class, 'sync']) + ->middleware('scope:write:repositories')->name('repositories.sync'); + Route::delete('repositories/{repository:uuid}', [RepositoryController::class, 'destroy']) + ->middleware('scope:delete:repositories')->name('repositories.destroy'); + + // Packages + Route::get('packages', [PackageController::class, 'index']) + ->middleware('scope:read:packages')->name('packages.index'); + Route::get('packages/{package:uuid}', [PackageController::class, 'show']) + ->middleware('scope:read:packages')->name('packages.show'); + Route::delete('packages/{package:uuid}', [PackageController::class, 'destroy']) + ->middleware('scope:delete:packages')->name('packages.destroy'); + Route::get('packages/{package:uuid}/versions', [PackageVersionController::class, 'index']) + ->middleware('scope:read:packages')->name('packages.versions.index'); + Route::delete('packages/{package:uuid}/versions/{version:uuid}', [PackageVersionController::class, 'destroy']) + ->middleware('scope:delete:packages')->name('packages.versions.destroy'); + + // Members + Route::get('members', [MemberController::class, 'index']) + ->middleware('scope:read:members')->name('members.index'); + Route::post('members', [MemberController::class, 'store']) + ->middleware('scope:write:members')->name('members.store'); + Route::patch('members/{member:uuid}', [MemberController::class, 'update']) + ->middleware('scope:write:members')->name('members.update'); + Route::delete('members/{member:uuid}', [MemberController::class, 'destroy']) + ->middleware('scope:write:members')->name('members.destroy'); + + // Invitations + Route::get('invitations', [InvitationController::class, 'index']) + ->middleware('scope:read:members')->name('invitations.index'); + Route::post('invitations/{invitation:uuid}/resend', [InvitationController::class, 'resend']) + ->middleware('scope:write:members')->name('invitations.resend'); + Route::delete('invitations/{invitation:uuid}', [InvitationController::class, 'destroy']) + ->middleware('scope:write:members')->name('invitations.destroy'); + + // Access tokens (organization scoped) + Route::get('tokens', [TokenController::class, 'index']) + ->middleware('scope:read:tokens')->name('tokens.index'); + Route::post('tokens', [TokenController::class, 'store']) + ->middleware('scope:write:tokens')->name('tokens.store'); + Route::patch('tokens/{token}', [TokenController::class, 'update']) + ->middleware('scope:write:tokens')->name('tokens.update'); + Route::delete('tokens/{token}', [TokenController::class, 'destroy']) + ->middleware('scope:write:tokens')->name('tokens.destroy'); + + // Mirrors + Route::get('mirrors', [MirrorController::class, 'index']) + ->middleware('scope:read:mirrors')->name('mirrors.index'); + Route::post('mirrors', [MirrorController::class, 'store']) + ->middleware('scope:write:mirrors')->name('mirrors.store'); + Route::get('mirrors/{mirror:uuid}', [MirrorController::class, 'show']) + ->middleware('scope:read:mirrors')->name('mirrors.show'); + Route::post('mirrors/{mirror:uuid}/sync', [MirrorController::class, 'sync']) + ->middleware('scope:write:mirrors')->name('mirrors.sync'); + Route::delete('mirrors/{mirror:uuid}', [MirrorController::class, 'destroy']) + ->middleware('scope:delete:mirrors')->name('mirrors.destroy'); + }); +}); diff --git a/routes/settings.php b/routes/settings.php index 4ce403d..a305be5 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -45,5 +45,6 @@ Route::get('settings/tokens', [UserTokenController::class, 'index'])->name('settings.tokens.index'); Route::post('settings/tokens', [UserTokenController::class, 'store'])->name('settings.tokens.store'); + Route::patch('settings/tokens/{token}', [UserTokenController::class, 'update'])->name('settings.tokens.update'); Route::delete('settings/tokens/{token}', [UserTokenController::class, 'destroy'])->name('settings.tokens.destroy'); }); diff --git a/routes/web.php b/routes/web.php index da16f97..eb09c34 100644 --- a/routes/web.php +++ b/routes/web.php @@ -87,6 +87,7 @@ Route::get('tokens', [TokenController::class, 'index'])->name('tokens.index'); Route::post('tokens', [TokenController::class, 'store'])->name('tokens.store'); + Route::patch('tokens/{token}', [TokenController::class, 'update'])->name('tokens.update'); Route::delete('tokens/{token}', [TokenController::class, 'destroy'])->name('tokens.destroy'); Route::get('ssh-keys', [SshKeyController::class, 'index'])->name('ssh-keys'); diff --git a/tests/Feature/Api/V1/AuthTest.php b/tests/Feature/Api/V1/AuthTest.php new file mode 100644 index 0000000..4a3c7ea --- /dev/null +++ b/tests/Feature/Api/V1/AuthTest.php @@ -0,0 +1,63 @@ +user = User::factory()->create(); + $this->organization = Organization::factory()->create(['owner_uuid' => $this->user->uuid]); + joinOrganization($this->organization, $this->user, OrganizationRole::Owner); +}); + +it('authenticates a valid personal access token', function () { + $token = personalAccessToken($this->user); + + $this->withToken($token) + ->getJson('/api/v1/user') + ->assertOk() + ->assertJson([ + 'uuid' => $this->user->uuid, + 'email' => $this->user->email, + ]); +}); + +it('rejects a request without an Authorization header', function () { + $this->getJson('/api/v1/user') + ->assertUnauthorized() + ->assertJson(['message' => 'Unauthenticated.']); +}); + +it('rejects an unknown token', function () { + $this->withToken('does-not-exist') + ->getJson('/api/v1/user') + ->assertUnauthorized(); +}); + +it('rejects an expired token', function () { + $plain = 'pat-expired-token'; + AccessToken::factory()->forUser($this->user)->withPlainToken($plain)->expired()->create(); + + $this->withToken($plain) + ->getJson('/api/v1/user') + ->assertUnauthorized(); +}); + +it('treats a null-scope (legacy) token as having full access', function () { + $token = personalAccessToken($this->user, scopes: null); + + // read:repositories is required, but a legacy token implicitly carries every scope. + $this->withToken($token) + ->getJson("/api/v1/organizations/{$this->organization->slug}/repositories") + ->assertOk(); +}); + +it('records last_used_at when a token authenticates', function () { + $token = personalAccessToken($this->user); + + $this->withToken($token)->getJson('/api/v1/user')->assertOk(); + + expect(AccessToken::query()->where('token_hash', hash('sha256', $token))->first()->last_used_at) + ->not->toBeNull(); +}); diff --git a/tests/Feature/Api/V1/MemberApiTest.php b/tests/Feature/Api/V1/MemberApiTest.php new file mode 100644 index 0000000..8fa0624 --- /dev/null +++ b/tests/Feature/Api/V1/MemberApiTest.php @@ -0,0 +1,58 @@ +owner = User::factory()->create(); + $this->organization = Organization::factory()->create(['owner_uuid' => $this->owner->uuid]); + joinOrganization($this->organization, $this->owner, OrganizationRole::Owner); +}); + +it('lists members with a pagination envelope', function () { + $token = personalAccessToken($this->owner, [TokenScope::ReadMembers]); + + $this->withToken($token) + ->getJson("/api/v1/organizations/{$this->organization->slug}/members") + ->assertOk() + ->assertJsonStructure(['data', 'meta', 'links']) + ->assertJsonPath('data.0.email', $this->owner->email); +}); + +it('invites a member', function () { + Notification::fake(); + + $token = personalAccessToken($this->owner, [TokenScope::WriteMembers]); + + $this->withToken($token) + ->postJson("/api/v1/organizations/{$this->organization->slug}/members", [ + 'email' => 'new@example.com', + 'role' => 'member', + ]) + ->assertCreated() + ->assertJsonPath('email', 'new@example.com'); + + assertDatabaseHas('organization_invitations', [ + 'organization_uuid' => $this->organization->uuid, + 'email' => 'new@example.com', + ]); +}); + +it('forbids a plain member from inviting', function () { + $member = User::factory()->create(); + joinOrganization($this->organization, $member, OrganizationRole::Member); + + $token = personalAccessToken($member, [TokenScope::WriteMembers]); + + $this->withToken($token) + ->postJson("/api/v1/organizations/{$this->organization->slug}/members", [ + 'email' => 'new@example.com', + 'role' => 'member', + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/Api/V1/OpenApiSpecTest.php b/tests/Feature/Api/V1/OpenApiSpecTest.php new file mode 100644 index 0000000..2fdb8f1 --- /dev/null +++ b/tests/Feature/Api/V1/OpenApiSpecTest.php @@ -0,0 +1,29 @@ + $path]); + + expect(file_exists($path))->toBeTrue(); + + $spec = json_decode((string) file_get_contents($path), true); + @unlink($path); + + expect($spec['openapi'])->toStartWith('3.'); + expect($spec['info']['title'])->toBe('Pricore API'); + expect($spec['paths'])->not->toBeEmpty(); + + // The bearer security scheme is documented and applied globally. + expect(array_keys($spec['components']['securitySchemes']))->toContain('http'); + expect($spec['components']['securitySchemes']['http']['scheme'])->toBe('bearer'); + expect($spec['security'])->toBe([['http' => []]]); + + // Core resources are present. + $paths = implode("\n", array_keys($spec['paths'])); + expect($paths)->toContain('organizations'); + expect($paths)->toContain('repositories'); + expect($paths)->toContain('tokens'); +}); diff --git a/tests/Feature/Api/V1/OrganizationApiTest.php b/tests/Feature/Api/V1/OrganizationApiTest.php new file mode 100644 index 0000000..f4b3151 --- /dev/null +++ b/tests/Feature/Api/V1/OrganizationApiTest.php @@ -0,0 +1,103 @@ +create(); + $token = personalAccessToken($user, [TokenScope::WriteOrganizations]); + + $response = $this->withToken($token) + ->postJson('/api/v1/organizations', ['name' => 'Acme Inc']) + ->assertCreated() + ->assertJsonPath('name', 'Acme Inc'); + + assertDatabaseHas('organizations', [ + 'uuid' => $response->json('uuid'), + 'owner_uuid' => $user->uuid, + ]); +}); + +it('does not allow organization-scoped tokens to create organizations', function () { + $owner = User::factory()->create(); + $organization = Organization::factory()->create(['owner_uuid' => $owner->uuid]); + joinOrganization($organization, $owner, OrganizationRole::Owner); + + $token = organizationAccessToken($organization, [TokenScope::WriteOrganizations]); + + $this->withToken($token) + ->postJson('/api/v1/organizations', ['name' => 'Nope']) + ->assertForbidden(); +}); + +it('shows an organization to a member', function () { + $user = User::factory()->create(); + $organization = Organization::factory()->create(['owner_uuid' => $user->uuid]); + joinOrganization($organization, $user, OrganizationRole::Owner); + + $token = personalAccessToken($user, [TokenScope::ReadOrganizations]); + + $this->withToken($token) + ->getJson("/api/v1/organizations/{$organization->slug}") + ->assertOk() + ->assertJsonPath('slug', $organization->slug); +}); + +it('updates an organization name and slug as the owner', function () { + $user = User::factory()->create(); + $organization = Organization::factory()->create(['owner_uuid' => $user->uuid]); + joinOrganization($organization, $user, OrganizationRole::Owner); + + $token = personalAccessToken($user, [TokenScope::WriteOrganizations]); + + $this->withToken($token) + ->patchJson("/api/v1/organizations/{$organization->slug}", [ + 'name' => 'Renamed Org', + 'slug' => 'renamed-org', + ]) + ->assertOk() + ->assertJsonPath('name', 'Renamed Org') + ->assertJsonPath('slug', 'renamed-org'); +}); + +it('forbids a plain member from updating the organization', function () { + $owner = User::factory()->create(); + $organization = Organization::factory()->create(['owner_uuid' => $owner->uuid]); + joinOrganization($organization, $owner, OrganizationRole::Owner); + + $member = User::factory()->create(); + joinOrganization($organization, $member, OrganizationRole::Member); + + $token = personalAccessToken($member, [TokenScope::WriteOrganizations]); + + $this->withToken($token) + ->patchJson("/api/v1/organizations/{$organization->slug}", ['name' => 'Hijacked']) + ->assertForbidden(); +}); + +it('deletes an organization as the owner but forbids an admin', function () { + $owner = User::factory()->create(); + $organization = Organization::factory()->create(['owner_uuid' => $owner->uuid]); + joinOrganization($organization, $owner, OrganizationRole::Owner); + + $admin = User::factory()->create(); + joinOrganization($organization, $admin, OrganizationRole::Admin); + + // Admin has the scope but not ownership — policy denies. + $adminToken = personalAccessToken($admin, [TokenScope::DeleteOrganizations]); + $this->withToken($adminToken) + ->deleteJson("/api/v1/organizations/{$organization->slug}") + ->assertForbidden(); + + $ownerToken = personalAccessToken($owner, [TokenScope::DeleteOrganizations]); + $this->withToken($ownerToken) + ->deleteJson("/api/v1/organizations/{$organization->slug}") + ->assertNoContent(); + + assertDatabaseMissing('organizations', ['uuid' => $organization->uuid, 'deleted_at' => null]); +}); diff --git a/tests/Feature/Api/V1/RepositoryApiTest.php b/tests/Feature/Api/V1/RepositoryApiTest.php new file mode 100644 index 0000000..9264ced --- /dev/null +++ b/tests/Feature/Api/V1/RepositoryApiTest.php @@ -0,0 +1,78 @@ +user = User::factory()->create(); + $this->organization = Organization::factory()->create(['owner_uuid' => $this->user->uuid]); + joinOrganization($this->organization, $this->user, OrganizationRole::Owner); +}); + +it('lists repositories with a pagination envelope', function () { + Repository::factory()->count(3)->create(['organization_uuid' => $this->organization->uuid]); + + $token = personalAccessToken($this->user, [TokenScope::ReadRepositories]); + + $this->withToken($token) + ->getJson("/api/v1/organizations/{$this->organization->slug}/repositories?per_page=2") + ->assertOk() + ->assertJsonStructure(['data', 'meta', 'links']) + ->assertJsonCount(2, 'data') + ->assertJsonPath('meta.total', 3); +}); + +it('creates a repository and dispatches a sync', function () { + Queue::fake(); + + $token = personalAccessToken($this->user, [TokenScope::WriteRepositories]); + + $this->withToken($token) + ->postJson("/api/v1/organizations/{$this->organization->slug}/repositories", [ + 'provider' => 'git', + 'repo_identifier' => 'https://example.com/acme/widgets.git', + ]) + ->assertCreated() + ->assertJsonPath('provider', 'git'); + + assertDatabaseHas('repositories', [ + 'organization_uuid' => $this->organization->uuid, + 'repo_identifier' => 'https://example.com/acme/widgets.git', + ]); + + Queue::assertPushed(SyncRepositoryJob::class); +}); + +it('deletes a repository with the delete scope', function () { + $repository = Repository::factory()->create(['organization_uuid' => $this->organization->uuid]); + + $token = personalAccessToken($this->user, [TokenScope::DeleteRepositories]); + + $this->withToken($token) + ->deleteJson("/api/v1/organizations/{$this->organization->slug}/repositories/{$repository->uuid}") + ->assertNoContent(); + + assertDatabaseMissing('repositories', ['uuid' => $repository->uuid]); +}); + +it('forbids a member from creating repositories', function () { + $member = User::factory()->create(); + joinOrganization($this->organization, $member, OrganizationRole::Member); + + $token = personalAccessToken($member, [TokenScope::WriteRepositories]); + + $this->withToken($token) + ->postJson("/api/v1/organizations/{$this->organization->slug}/repositories", [ + 'provider' => 'git', + 'repo_identifier' => 'https://example.com/acme/widgets.git', + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/Api/V1/ScopeEnforcementTest.php b/tests/Feature/Api/V1/ScopeEnforcementTest.php new file mode 100644 index 0000000..dee6cc0 --- /dev/null +++ b/tests/Feature/Api/V1/ScopeEnforcementTest.php @@ -0,0 +1,41 @@ +user = User::factory()->create(); + $this->organization = Organization::factory()->create(['owner_uuid' => $this->user->uuid]); + joinOrganization($this->organization, $this->user, OrganizationRole::Owner); +}); + +it('allows a request when the token carries the required scope', function () { + $token = personalAccessToken($this->user, [TokenScope::ReadRepositories]); + + $this->withToken($token) + ->getJson("/api/v1/organizations/{$this->organization->slug}/repositories") + ->assertOk(); +}); + +it('rejects a request when the token lacks the required scope', function () { + $token = personalAccessToken($this->user, [TokenScope::ReadPackages]); + + $this->withToken($token) + ->getJson("/api/v1/organizations/{$this->organization->slug}/repositories") + ->assertForbidden() + ->assertJson(['required_scope' => 'read:repositories']); +}); + +it('requires a delete scope for destructive operations', function () { + // Has write but not delete — must not be able to delete. + $token = personalAccessToken($this->user, [TokenScope::WriteRepositories]); + $repository = Repository::factory()->create(['organization_uuid' => $this->organization->uuid]); + + $this->withToken($token) + ->deleteJson("/api/v1/organizations/{$this->organization->slug}/repositories/{$repository->uuid}") + ->assertForbidden() + ->assertJson(['required_scope' => 'delete:repositories']); +}); diff --git a/tests/Feature/Api/V1/TokenApiTest.php b/tests/Feature/Api/V1/TokenApiTest.php new file mode 100644 index 0000000..0165a15 --- /dev/null +++ b/tests/Feature/Api/V1/TokenApiTest.php @@ -0,0 +1,138 @@ +user = User::factory()->create(); + $this->organization = Organization::factory()->create(['owner_uuid' => $this->user->uuid]); + joinOrganization($this->organization, $this->user, OrganizationRole::Owner); +}); + +it('creates an organization token with scopes and returns the plaintext once', function () { + // A full-access (legacy) personal token may grant any scope. + $token = personalAccessToken($this->user, scopes: null); + + $response = $this->withToken($token) + ->postJson("/api/v1/organizations/{$this->organization->slug}/tokens", [ + 'name' => 'CI token', + 'scopes' => ['composer', 'read:packages'], + ]) + ->assertCreated() + ->assertJsonPath('name', 'CI token') + ->assertJsonPath('scopes', ['composer', 'read:packages']); + + expect($response->json('plainToken'))->toBeString()->not->toBeEmpty(); + + assertDatabaseHas('access_tokens', [ + 'organization_uuid' => $this->organization->uuid, + 'name' => 'CI token', + ]); +}); + +it('defaults new tokens to composer scope when none are given', function () { + $token = personalAccessToken($this->user, scopes: null); + + $this->withToken($token) + ->postJson("/api/v1/organizations/{$this->organization->slug}/tokens", ['name' => 'Composer']) + ->assertCreated() + ->assertJsonPath('scopes', ['composer']); +}); + +it('prevents a scoped token from granting scopes it does not hold', function () { + // This token can write tokens, but does not itself hold delete:packages. + $token = personalAccessToken($this->user, [TokenScope::WriteTokens, TokenScope::ReadPackages]); + + $this->withToken($token) + ->postJson("/api/v1/organizations/{$this->organization->slug}/tokens", [ + 'name' => 'Escalated', + 'scopes' => ['delete:packages'], + ]) + ->assertForbidden(); +}); + +it('creates a personal access token via the user endpoint', function () { + $token = personalAccessToken($this->user, scopes: null); + + $response = $this->withToken($token) + ->postJson('/api/v1/user/tokens', [ + 'name' => 'My laptop', + 'scopes' => ['composer'], + ]) + ->assertCreated() + ->assertJsonPath('name', 'My laptop'); + + expect($response->json('plainToken'))->toBeString()->not->toBeEmpty(); + + assertDatabaseHas('access_tokens', [ + 'user_uuid' => $this->user->uuid, + 'name' => 'My laptop', + ]); +}); + +it('rejects invalid scopes', function () { + $token = personalAccessToken($this->user, scopes: null); + + $this->withToken($token) + ->postJson("/api/v1/organizations/{$this->organization->slug}/tokens", [ + 'name' => 'Bad', + 'scopes' => ['not-a-real-scope'], + ]) + ->assertStatus(422); +}); + +it('updates an organization token name and scopes', function () { + $token = personalAccessToken($this->user, scopes: null); + $target = AccessToken::factory() + ->forOrganization($this->organization) + ->withScopes([TokenScope::Composer]) + ->create(); + + $this->withToken($token) + ->patchJson("/api/v1/organizations/{$this->organization->slug}/tokens/{$target->uuid}", [ + 'name' => 'Renamed', + 'scopes' => ['composer', 'write:repositories'], + ]) + ->assertOk() + ->assertJsonPath('name', 'Renamed') + ->assertJsonPath('scopes', ['composer', 'write:repositories']); +}); + +it('keeps existing scopes on a name-only update', function () { + $token = personalAccessToken($this->user, scopes: null); + $target = AccessToken::factory() + ->forOrganization($this->organization) + ->withScopes([TokenScope::Composer, TokenScope::ReadPackages]) + ->create(); + + $this->withToken($token) + ->patchJson("/api/v1/organizations/{$this->organization->slug}/tokens/{$target->uuid}", [ + 'name' => 'Just renamed', + ]) + ->assertOk() + ->assertJsonPath('name', 'Just renamed') + ->assertJsonPath('scopes', ['composer', 'read:packages']); +}); + +it('prevents granting scopes the caller lacks on update', function () { + $token = personalAccessToken($this->user, [ + TokenScope::WriteTokens, + TokenScope::ReadPackages, + ]); + $target = AccessToken::factory() + ->forOrganization($this->organization) + ->withScopes([TokenScope::Composer]) + ->create(); + + $this->withToken($token) + ->patchJson("/api/v1/organizations/{$this->organization->slug}/tokens/{$target->uuid}", [ + 'name' => 'Escalate', + 'scopes' => ['delete:packages'], + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/Api/V1/TokenContextTest.php b/tests/Feature/Api/V1/TokenContextTest.php new file mode 100644 index 0000000..8d56aa2 --- /dev/null +++ b/tests/Feature/Api/V1/TokenContextTest.php @@ -0,0 +1,75 @@ +create(); + $orgA = Organization::factory()->create(['owner_uuid' => $user->uuid]); + $orgB = Organization::factory()->create(['owner_uuid' => $user->uuid]); + joinOrganization($orgA, $user, OrganizationRole::Owner); + joinOrganization($orgB, $user, OrganizationRole::Owner); + + $token = personalAccessToken($user, [TokenScope::ReadRepositories]); + + $this->withToken($token)->getJson("/api/v1/organizations/{$orgA->slug}/repositories")->assertOk(); + $this->withToken($token)->getJson("/api/v1/organizations/{$orgB->slug}/repositories")->assertOk(); +}); + +it('forbids a personal access token from organizations the user does not belong to', function () { + $user = User::factory()->create(); + $orgA = Organization::factory()->create(['owner_uuid' => $user->uuid]); + joinOrganization($orgA, $user, OrganizationRole::Owner); + + $otherOrg = Organization::factory()->create(); + + $token = personalAccessToken($user, [TokenScope::ReadRepositories]); + + $this->withToken($token) + ->getJson("/api/v1/organizations/{$otherOrg->slug}/repositories") + ->assertForbidden(); +}); + +it('isolates an organization-scoped token to its own organization', function () { + $owner = User::factory()->create(); + $orgA = Organization::factory()->create(['owner_uuid' => $owner->uuid]); + $orgB = Organization::factory()->create(['owner_uuid' => $owner->uuid]); + joinOrganization($orgA, $owner, OrganizationRole::Owner); + joinOrganization($orgB, $owner, OrganizationRole::Owner); + + $token = organizationAccessToken($orgA, [TokenScope::ReadRepositories]); + + $this->withToken($token)->getJson("/api/v1/organizations/{$orgA->slug}/repositories")->assertOk(); + + // Even though the owner is a member of org B, the org-A token must not reach it. + $this->withToken($token) + ->getJson("/api/v1/organizations/{$orgB->slug}/repositories") + ->assertForbidden(); +}); + +it('forbids organization-scoped tokens from personal endpoints', function () { + $owner = User::factory()->create(); + $organization = Organization::factory()->create(['owner_uuid' => $owner->uuid]); + joinOrganization($organization, $owner, OrganizationRole::Owner); + + $token = organizationAccessToken($organization); + + $this->withToken($token)->getJson('/api/v1/user')->assertForbidden(); +}); + +it('limits the organization listing of an organization token to its own organization', function () { + $owner = User::factory()->create(); + $orgA = Organization::factory()->create(['owner_uuid' => $owner->uuid]); + $orgB = Organization::factory()->create(['owner_uuid' => $owner->uuid]); + joinOrganization($orgA, $owner, OrganizationRole::Owner); + joinOrganization($orgB, $owner, OrganizationRole::Owner); + + $token = organizationAccessToken($orgA, [TokenScope::ReadOrganizations]); + + $response = $this->withToken($token)->getJson('/api/v1/organizations')->assertOk(); + + expect($response->json('data'))->toHaveCount(1); + expect($response->json('data.0.uuid'))->toBe($orgA->uuid); +}); diff --git a/tests/Feature/Composer/ComposerScopeTest.php b/tests/Feature/Composer/ComposerScopeTest.php new file mode 100644 index 0000000..73feb4a --- /dev/null +++ b/tests/Feature/Composer/ComposerScopeTest.php @@ -0,0 +1,32 @@ +organization = Organization::factory()->create(['slug' => 'acme']); +}); + +it('allows registry access for a token with the composer scope', function () { + $token = organizationAccessToken($this->organization, [TokenScope::Composer]); + + $this->withToken($token) + ->getJson('/acme/packages.json') + ->assertOk(); +}); + +it('allows registry access for a legacy null-scope token', function () { + $token = organizationAccessToken($this->organization, scopes: null); + + $this->withToken($token) + ->getJson('/acme/packages.json') + ->assertOk(); +}); + +it('forbids registry access for a token without the composer scope', function () { + $token = organizationAccessToken($this->organization, [TokenScope::ReadRepositories]); + + $this->withToken($token) + ->getJson('/acme/packages.json') + ->assertForbidden(); +}); diff --git a/tests/Feature/Settings/UserTokenScopeTest.php b/tests/Feature/Settings/UserTokenScopeTest.php new file mode 100644 index 0000000..d9bb198 --- /dev/null +++ b/tests/Feature/Settings/UserTokenScopeTest.php @@ -0,0 +1,50 @@ +create(); + + actingAs($user)->post(route('settings.tokens.store'), [ + 'name' => 'Scoped token', + 'scopes' => ['composer', 'read:repositories'], + ]); + + $token = AccessToken::query()->where('user_uuid', $user->uuid)->firstOrFail(); + + expect($token->scopes)->toEqual(['composer', 'read:repositories']); +}); + +it('defaults to the composer scope when none are selected', function () { + $user = User::factory()->create(); + + actingAs($user)->post(route('settings.tokens.store'), [ + 'name' => 'Default token', + ]); + + $token = AccessToken::query()->where('user_uuid', $user->uuid)->firstOrFail(); + + expect($token->scopes)->toEqual(['composer']); +}); + +it('updates a personal token name and scopes', function () { + $user = User::factory()->create(); + $token = AccessToken::factory() + ->forUser($user) + ->withScopes([TokenScope::Composer]) + ->create(); + + actingAs($user)->patch(route('settings.tokens.update', $token->uuid), [ + 'name' => 'Renamed token', + 'scopes' => ['composer', 'read:repositories'], + ]); + + $token->refresh(); + + expect($token->name)->toBe('Renamed token'); + expect($token->scopes)->toEqual(['composer', 'read:repositories']); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 6885ad5..2c4a946 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,12 @@ members()->attach($user->uuid, [ + 'uuid' => (string) Str::uuid(), + 'role' => $role->value, + ]); +} + +/** + * Create a personal access token for a user and return its plaintext value. + * + * @param array|null $scopes Null grants full (legacy) access. + */ +function personalAccessToken(User $user, ?array $scopes = null): string +{ + $plain = 'pat-'.Str::random(48); + + AccessToken::factory() + ->forUser($user) + ->withPlainToken($plain) + ->neverExpires() + ->state(['scopes' => $scopes === null ? null : array_map(fn (TokenScope $s) => $s->value, $scopes)]) + ->create(); + + return $plain; +} + +/** + * Create an organization-scoped access token and return its plaintext value. + * + * @param array|null $scopes Null grants full (legacy) access. + */ +function organizationAccessToken(Organization $organization, ?array $scopes = null): string +{ + $plain = 'org-'.Str::random(48); + + AccessToken::factory() + ->forOrganization($organization) + ->withPlainToken($plain) + ->neverExpires() + ->state(['scopes' => $scopes === null ? null : array_map(fn (TokenScope $s) => $s->value, $scopes)]) + ->create(); + + return $plain; +}