From 6029551abf82a5dc8a15aa82f4bfb88814e91d81 Mon Sep 17 00:00:00 2001 From: Maarten Bode Date: Sun, 19 Jul 2026 12:12:31 +0200 Subject: [PATCH] feat(composer): add per-organization anonymous pulling toggle --- .../Contracts/Data/OrganizationData.php | 2 + .../SecuritySettingsController.php | 1 + app/Http/Middleware/ComposerTokenAuth.php | 35 +++++---- app/Models/Organization.php | 3 + ...nonymous_access_to_organizations_table.php | 22 ++++++ .../pages/organizations/settings/security.tsx | 74 ++++++++++++++----- resources/types/generated.d.ts | 1 + tests/Feature/Composer/ComposerApiTest.php | 20 +++++ .../Middleware/ComposerTokenAuthTest.php | 26 +++++++ .../Feature/Security/SecuritySettingsTest.php | 54 ++++++++++++++ 10 files changed, 203 insertions(+), 35 deletions(-) create mode 100644 database/migrations/2026_06_20_000000_add_anonymous_access_to_organizations_table.php create mode 100644 tests/Feature/Security/SecuritySettingsTest.php diff --git a/app/Domains/Organization/Contracts/Data/OrganizationData.php b/app/Domains/Organization/Contracts/Data/OrganizationData.php index 246dda24..d9ee7f53 100644 --- a/app/Domains/Organization/Contracts/Data/OrganizationData.php +++ b/app/Domains/Organization/Contracts/Data/OrganizationData.php @@ -20,6 +20,7 @@ public function __construct( public ?bool $trialExpired = null, public bool $securityAuditsEnabled = true, public bool $securityNotificationsEnabled = true, + public bool $anonymousAccessEnabled = false, ) {} public static function fromModel(Organization $organization): self @@ -32,6 +33,7 @@ public static function fromModel(Organization $organization): self composerRepositoryUrl: url("/{$organization->slug}"), securityAuditsEnabled: $organization->security_audits_enabled, securityNotificationsEnabled: $organization->security_notifications_enabled, + anonymousAccessEnabled: $organization->anonymous_access_enabled, ); } } diff --git a/app/Domains/Security/Http/Controllers/SecuritySettingsController.php b/app/Domains/Security/Http/Controllers/SecuritySettingsController.php index 824780b2..c03ca16a 100644 --- a/app/Domains/Security/Http/Controllers/SecuritySettingsController.php +++ b/app/Domains/Security/Http/Controllers/SecuritySettingsController.php @@ -31,6 +31,7 @@ public function update(Request $request, Organization $organization): RedirectRe $validated = $request->validate([ 'security_audits_enabled' => ['required', 'boolean'], 'security_notifications_enabled' => ['required', 'boolean'], + 'anonymous_access_enabled' => ['required', 'boolean'], ]); $organization->update($validated); diff --git a/app/Http/Middleware/ComposerTokenAuth.php b/app/Http/Middleware/ComposerTokenAuth.php index 85a1bfc5..13a4837e 100644 --- a/app/Http/Middleware/ComposerTokenAuth.php +++ b/app/Http/Middleware/ComposerTokenAuth.php @@ -12,34 +12,39 @@ class ComposerTokenAuth { public function handle(Request $request, Closure $next): Response { + $organization = $this->resolveOrganization($request); + $token = $this->extractToken($request); + $accessToken = $token ? $this->findAccessToken($token) : null; + + if ($accessToken && $accessToken->isValid() && $this->canAccessOrganization($accessToken, $organization)) { + $accessToken->markAsUsed(); + + $request->merge(['accessToken' => $accessToken]); - if (! $token) { - return $this->unauthorized(); + return $next($request); } - $accessToken = $this->findAccessToken($token); + // Fall back to anonymous access when the organization allows it + if ($organization?->anonymous_access_enabled) { + $request->merge(['accessToken' => null]); - if (! $accessToken || ! $accessToken->isValid()) { - return $this->unauthorized(); + return $next($request); } + return $this->unauthorized(); + } + + protected function resolveOrganization(Request $request): ?Organization + { $organization = $request->route('organization'); // Resolve the organization from slug if route model binding hasn't run yet if (is_string($organization)) { - $organization = Organization::where('slug', $organization)->first(); - } - - if (! $this->canAccessOrganization($accessToken, $organization)) { - return $this->unauthorized(); + return Organization::where('slug', $organization)->first(); } - $accessToken->markAsUsed(); - - $request->merge(['accessToken' => $accessToken]); - - return $next($request); + return $organization instanceof Organization ? $organization : null; } protected function extractToken(Request $request): ?string diff --git a/app/Models/Organization.php b/app/Models/Organization.php index f2563609..e53f8af6 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -23,6 +23,7 @@ * @property Carbon|null $trial_ends_at * @property bool $security_audits_enabled * @property bool $security_notifications_enabled + * @property bool $anonymous_access_enabled * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property Carbon|null $deleted_at @@ -73,6 +74,7 @@ class Organization extends Model protected $attributes = [ 'security_audits_enabled' => true, 'security_notifications_enabled' => true, + 'anonymous_access_enabled' => false, ]; /** @@ -84,6 +86,7 @@ protected function casts(): array 'trial_ends_at' => 'datetime', 'security_audits_enabled' => 'boolean', 'security_notifications_enabled' => 'boolean', + 'anonymous_access_enabled' => 'boolean', ]; } diff --git a/database/migrations/2026_06_20_000000_add_anonymous_access_to_organizations_table.php b/database/migrations/2026_06_20_000000_add_anonymous_access_to_organizations_table.php new file mode 100644 index 00000000..f7d55f25 --- /dev/null +++ b/database/migrations/2026_06_20_000000_add_anonymous_access_to_organizations_table.php @@ -0,0 +1,22 @@ +boolean('anonymous_access_enabled')->default(false); + }); + } + + public function down(): void + { + Schema::table('organizations', function (Blueprint $table) { + $table->dropColumn('anonymous_access_enabled'); + }); + } +}; diff --git a/resources/js/pages/organizations/settings/security.tsx b/resources/js/pages/organizations/settings/security.tsx index 10645770..48d90cff 100644 --- a/resources/js/pages/organizations/settings/security.tsx +++ b/resources/js/pages/organizations/settings/security.tsx @@ -1,8 +1,10 @@ import { update } from '@/actions/App/Domains/Security/Http/Controllers/SecuritySettingsController'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { withOrganizationSettingsLayout } from '@/layouts/organization-settings-layout'; import { router } from '@inertiajs/react'; +import { TriangleAlert } from 'lucide-react'; type OrganizationData = App.Domains.Organization.Contracts.Data.OrganizationData; @@ -12,14 +14,16 @@ interface Props { } export default function Security({ organization }: Props) { - const handleToggle = ( - field: string, - value: boolean, - otherFields: Record, - ) => { + const updateSettings = (overrides: Record) => { router.patch( update.url(organization.slug), - { [field]: value, ...otherFields }, + { + security_audits_enabled: organization.securityAuditsEnabled, + security_notifications_enabled: + organization.securityNotificationsEnabled, + anonymous_access_enabled: organization.anonymousAccessEnabled, + ...overrides, + }, { preserveScroll: true }, ); }; @@ -29,8 +33,8 @@ export default function Security({ organization }: Props) {

Security Settings

- Configure security auditing and notifications for your - organization + Configure security auditing, notifications, and registry + access for your organization

@@ -47,10 +51,7 @@ export default function Security({ organization }: Props) { id="security-audits" checked={organization.securityAuditsEnabled} onCheckedChange={(checked) => - handleToggle('security_audits_enabled', checked, { - security_notifications_enabled: - organization.securityNotificationsEnabled, - }) + updateSettings({ security_audits_enabled: checked }) } /> @@ -70,17 +71,50 @@ export default function Security({ organization }: Props) { checked={organization.securityNotificationsEnabled} disabled={!organization.securityAuditsEnabled} onCheckedChange={(checked) => - handleToggle( - 'security_notifications_enabled', - checked, - { - security_audits_enabled: - organization.securityAuditsEnabled, - }, - ) + updateSettings({ + security_notifications_enabled: checked, + }) + } + /> + + + +
+
+
+ +

+ Allow pulling packages without a Composer + authentication token +

+
+ + updateSettings({ + anonymous_access_enabled: checked, + }) } />
+ + {organization.anonymousAccessEnabled && ( + + + + All packages are publicly accessible + + + Anyone who can reach this registry can pull every + package in this organization without authentication. + Only enable this if access is restricted by other + means. + + + )}
); diff --git a/resources/types/generated.d.ts b/resources/types/generated.d.ts index 4a529942..d461250a 100644 --- a/resources/types/generated.d.ts +++ b/resources/types/generated.d.ts @@ -78,6 +78,7 @@ onTrial: boolean | null; trialExpired: boolean | null; securityAuditsEnabled: boolean; securityNotificationsEnabled: boolean; +anonymousAccessEnabled: boolean; }; export type OrganizationInvitationData = { uuid: string; diff --git a/tests/Feature/Composer/ComposerApiTest.php b/tests/Feature/Composer/ComposerApiTest.php index 35690db6..acb3b9e0 100644 --- a/tests/Feature/Composer/ComposerApiTest.php +++ b/tests/Feature/Composer/ComposerApiTest.php @@ -418,6 +418,26 @@ function authenticatedGet(string $uri, string $token): TestResponse ->assertHeader('ETag'); }); +it('serves metadata without a token when anonymous access is enabled', function () { + $this->organization->update(['anonymous_access_enabled' => true]); + + $package = Package::factory() + ->for($this->organization, 'organization') + ->create(['name' => 'acme/awesome-package']); + + PackageVersion::factory() + ->for($package) + ->create(['version' => '1.0.0', 'normalized_version' => '1.0.0.0']); + + test()->getJson("/{$this->organization->slug}/packages.json") + ->assertOk() + ->assertJsonPath('available-packages', ['acme/awesome-package']); + + test()->getJson("/{$this->organization->slug}/p2/acme/awesome-package.json") + ->assertOk() + ->assertJsonPath('packages.acme/awesome-package.0.version', '1.0.0'); +}); + it('does not leak packages from other organizations', function () { $otherOrg = Organization::factory()->create(['slug' => 'other-org']); diff --git a/tests/Feature/Middleware/ComposerTokenAuthTest.php b/tests/Feature/Middleware/ComposerTokenAuthTest.php index 92e4ae89..1a533ba0 100644 --- a/tests/Feature/Middleware/ComposerTokenAuthTest.php +++ b/tests/Feature/Middleware/ComposerTokenAuthTest.php @@ -73,3 +73,29 @@ ['Authorization' => 'Basic '.$credentials] )->assertUnauthorized(); }); + +it('allows anonymous access when the organization enables it', function () { + $organization = Organization::factory()->create(['anonymous_access_enabled' => true]); + + $this->getJson( + route('composer.packages.index', $organization), + )->assertSuccessful(); +}); + +it('falls back to anonymous access for an invalid token when enabled', function () { + $organization = Organization::factory()->create(['anonymous_access_enabled' => true]); + + $this->getJson( + route('composer.packages.index', $organization), + ['Authorization' => 'Bearer not-a-real-token'] + )->assertSuccessful(); +}); + +it('still serves a valid token when anonymous access is enabled', function () { + $this->organization->update(['anonymous_access_enabled' => true]); + + $this->getJson( + route('composer.packages.index', $this->organization), + ['Authorization' => 'Bearer '.$this->plainToken] + )->assertSuccessful(); +}); diff --git a/tests/Feature/Security/SecuritySettingsTest.php b/tests/Feature/Security/SecuritySettingsTest.php new file mode 100644 index 00000000..29308a9c --- /dev/null +++ b/tests/Feature/Security/SecuritySettingsTest.php @@ -0,0 +1,54 @@ +owner = User::factory()->create(); + $this->organization = Organization::factory()->create(['owner_uuid' => $this->owner->uuid]); + $this->organization->members()->attach($this->owner->uuid, [ + 'uuid' => Str::uuid()->toString(), + 'role' => OrganizationRole::Owner->value, + ]); +}); + +it('persists the anonymous access toggle', function () { + $this->actingAs($this->owner) + ->patch(route('organizations.settings.security.update', $this->organization), [ + 'security_audits_enabled' => true, + 'security_notifications_enabled' => true, + 'anonymous_access_enabled' => true, + ]) + ->assertRedirect(); + + expect($this->organization->fresh()->anonymous_access_enabled)->toBeTrue(); +}); + +it('requires the anonymous access field', function () { + $this->actingAs($this->owner) + ->patch(route('organizations.settings.security.update', $this->organization), [ + 'security_audits_enabled' => true, + 'security_notifications_enabled' => true, + ]) + ->assertSessionHasErrors('anonymous_access_enabled'); +}); + +it('forbids a plain member from changing the anonymous access toggle', function () { + $member = User::factory()->create(); + $this->organization->members()->attach($member->uuid, [ + 'uuid' => Str::uuid()->toString(), + 'role' => OrganizationRole::Member->value, + ]); + + $this->actingAs($member) + ->patch(route('organizations.settings.security.update', $this->organization), [ + 'security_audits_enabled' => true, + 'security_notifications_enabled' => true, + 'anonymous_access_enabled' => true, + ]) + ->assertForbidden(); + + expect($this->organization->fresh()->anonymous_access_enabled)->toBeFalse(); +});