Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/Domains/Organization/Contracts/Data/OrganizationData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 20 additions & 15 deletions app/Http/Middleware/ComposerTokenAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/Models/Organization.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,6 +74,7 @@ class Organization extends Model
protected $attributes = [
'security_audits_enabled' => true,
'security_notifications_enabled' => true,
'anonymous_access_enabled' => false,
];

/**
Expand All @@ -84,6 +86,7 @@ protected function casts(): array
'trial_ends_at' => 'datetime',
'security_audits_enabled' => 'boolean',
'security_notifications_enabled' => 'boolean',
'anonymous_access_enabled' => 'boolean',
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('organizations', function (Blueprint $table) {
$table->boolean('anonymous_access_enabled')->default(false);
});
}

public function down(): void
{
Schema::table('organizations', function (Blueprint $table) {
$table->dropColumn('anonymous_access_enabled');
});
}
};
74 changes: 54 additions & 20 deletions resources/js/pages/organizations/settings/security.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,14 +14,16 @@ interface Props {
}

export default function Security({ organization }: Props) {
const handleToggle = (
field: string,
value: boolean,
otherFields: Record<string, boolean>,
) => {
const updateSettings = (overrides: Record<string, boolean>) => {
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 },
);
};
Expand All @@ -29,8 +33,8 @@ export default function Security({ organization }: Props) {
<div>
<h3 className="text-lg font-medium">Security Settings</h3>
<p className="text-muted-foreground">
Configure security auditing and notifications for your
organization
Configure security auditing, notifications, and registry
access for your organization
</p>
</div>

Expand All @@ -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 })
}
/>
</div>
Expand All @@ -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,
})
}
/>
</div>
</div>

<div className="space-y-4 rounded-lg border bg-card p-6">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="anonymous-access">
Anonymous access
</Label>
<p className="text-sm text-muted-foreground">
Allow pulling packages without a Composer
authentication token
</p>
</div>
<Switch
id="anonymous-access"
checked={organization.anonymousAccessEnabled}
onCheckedChange={(checked) =>
updateSettings({
anonymous_access_enabled: checked,
})
}
/>
</div>

{organization.anonymousAccessEnabled && (
<Alert variant="destructive">
<TriangleAlert />
<AlertTitle>
All packages are publicly accessible
</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
)}
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions resources/types/generated.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ onTrial: boolean | null;
trialExpired: boolean | null;
securityAuditsEnabled: boolean;
securityNotificationsEnabled: boolean;
anonymousAccessEnabled: boolean;
};
export type OrganizationInvitationData = {
uuid: string;
Expand Down
20 changes: 20 additions & 0 deletions tests/Feature/Composer/ComposerApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down
26 changes: 26 additions & 0 deletions tests/Feature/Middleware/ComposerTokenAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
54 changes: 54 additions & 0 deletions tests/Feature/Security/SecuritySettingsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

use App\Domains\Organization\Contracts\Enums\OrganizationRole;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Str;

beforeEach(function () {
$this->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();
});