fix(onboarding): preserve workspace scope and consent flow #117
@ -506,6 +506,12 @@ private function resumeLatestOnboardingSessionIfUnambiguous(): void
|
|||||||
|
|
||||||
private function initializeWizardData(): void
|
private function initializeWizardData(): void
|
||||||
{
|
{
|
||||||
|
// Ensure all entangled schema state paths exist at render time.
|
||||||
|
// Livewire v4 can throw when entangling to missing nested array keys.
|
||||||
|
$this->data['notes'] ??= '';
|
||||||
|
$this->data['override_blocked'] ??= false;
|
||||||
|
$this->data['override_reason'] ??= '';
|
||||||
|
|
||||||
if (! array_key_exists('connection_mode', $this->data)) {
|
if (! array_key_exists('connection_mode', $this->data)) {
|
||||||
$this->data['connection_mode'] = 'existing';
|
$this->data['connection_mode'] = 'existing';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1167,17 +1167,10 @@ public static function adminConsentUrl(Tenant $tenant): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build explicit scope list from required permissions
|
// Admin consent should use `.default` so the tenant consents to the app's configured
|
||||||
$requiredPermissions = config('intune_permissions.permissions', []);
|
// application permissions. Keeping the URL short also avoids edge cases where a long
|
||||||
$scopes = collect($requiredPermissions)
|
// scope string gets truncated and causes AADSTS900144 (missing `scope`).
|
||||||
->pluck('key')
|
|
||||||
->map(fn (string $permission) => "https://graph.microsoft.com/{$permission}")
|
|
||||||
->join(' ');
|
|
||||||
|
|
||||||
// Fallback to .default if no permissions configured
|
|
||||||
if (empty($scopes)) {
|
|
||||||
$scopes = 'https://graph.microsoft.com/.default';
|
$scopes = 'https://graph.microsoft.com/.default';
|
||||||
}
|
|
||||||
|
|
||||||
$query = http_build_query([
|
$query = http_build_query([
|
||||||
'client_id' => $clientId,
|
'client_id' => $clientId,
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public function __invoke(
|
|||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
): View {
|
): View {
|
||||||
$expectedState = $request->session()->pull('tenant_onboard_state');
|
$expectedState = $request->session()->pull('tenant_onboard_state');
|
||||||
|
$workspaceId = $request->session()->pull('tenant_onboard_workspace_id');
|
||||||
$tenantKey = $request->string('tenant')->toString();
|
$tenantKey = $request->string('tenant')->toString();
|
||||||
$state = $request->string('state')->toString();
|
$state = $request->string('state')->toString();
|
||||||
$tenantIdentifier = $tenantKey ?: $this->parseState($state);
|
$tenantIdentifier = $tenantKey ?: $this->parseState($state);
|
||||||
@ -30,7 +31,7 @@ public function __invoke(
|
|||||||
|
|
||||||
abort_if(empty($tenantIdentifier), 404);
|
abort_if(empty($tenantIdentifier), 404);
|
||||||
|
|
||||||
$tenant = $this->resolveTenant($tenantIdentifier);
|
$tenant = $this->resolveTenant($tenantIdentifier, is_numeric($workspaceId) ? (int) $workspaceId : null);
|
||||||
|
|
||||||
$error = $request->string('error')->toString() ?: null;
|
$error = $request->string('error')->toString() ?: null;
|
||||||
$consentGranted = $request->has('admin_consent')
|
$consentGranted = $request->has('admin_consent')
|
||||||
@ -75,7 +76,7 @@ public function __invoke(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTenant(string $tenantIdentifier): Tenant
|
private function resolveTenant(string $tenantIdentifier, ?int $workspaceId): Tenant
|
||||||
{
|
{
|
||||||
/** @var Tenant|null $tenant */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = Tenant::withTrashed()
|
$tenant = Tenant::withTrashed()
|
||||||
@ -87,12 +88,19 @@ private function resolveTenant(string $tenantIdentifier): Tenant
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
|
if ($tenant->workspace_id === null && $workspaceId !== null) {
|
||||||
|
$tenant->forceFill(['workspace_id' => $workspaceId])->save();
|
||||||
|
}
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_if($workspaceId === null, ResponseAlias::HTTP_FORBIDDEN, 'Missing workspace context');
|
||||||
|
|
||||||
return Tenant::create([
|
return Tenant::create([
|
||||||
'tenant_id' => $tenantIdentifier,
|
'tenant_id' => $tenantIdentifier,
|
||||||
'name' => 'New Tenant',
|
'name' => 'New Tenant',
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@ -20,6 +21,12 @@ public function __invoke(Request $request): RedirectResponse
|
|||||||
$state = Str::uuid()->toString();
|
$state = Str::uuid()->toString();
|
||||||
$request->session()->put('tenant_onboard_state', $state);
|
$request->session()->put('tenant_onboard_state', $state);
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId($request);
|
||||||
|
|
||||||
|
if ($workspaceId !== null) {
|
||||||
|
$request->session()->put('tenant_onboard_workspace_id', (int) $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
$url = "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
$url = "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
|
||||||
'client_id' => $clientId,
|
'client_id' => $clientId,
|
||||||
'redirect_uri' => $redirectUri,
|
'redirect_uri' => $redirectUri,
|
||||||
|
|||||||
@ -153,6 +153,7 @@ public function sync(Tenant $tenant, string $selectionKey): array
|
|||||||
$groupTypes = $item['groupTypes'] ?? null;
|
$groupTypes = $item['groupTypes'] ?? null;
|
||||||
|
|
||||||
$values = [
|
$values = [
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'display_name' => is_string($displayName) ? $displayName : $entraId,
|
'display_name' => is_string($displayName) ? $displayName : $entraId,
|
||||||
'group_types' => is_array($groupTypes) ? $groupTypes : [],
|
'group_types' => is_array($groupTypes) ? $groupTypes : [],
|
||||||
'security_enabled' => (bool) ($item['securityEnabled'] ?? false),
|
'security_enabled' => (bool) ($item['securityEnabled'] ?? false),
|
||||||
|
|||||||
@ -148,6 +148,7 @@ public function sync(Tenant $tenant): array
|
|||||||
$isBuiltIn = (bool) ($item['isBuiltIn'] ?? false);
|
$isBuiltIn = (bool) ($item['isBuiltIn'] ?? false);
|
||||||
|
|
||||||
$values = [
|
$values = [
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'display_name' => is_string($displayName) ? $displayName : $entraId,
|
'display_name' => is_string($displayName) ? $displayName : $entraId,
|
||||||
'is_built_in' => $isBuiltIn,
|
'is_built_in' => $isBuiltIn,
|
||||||
'last_seen_at' => $nowUtc,
|
'last_seen_at' => $nowUtc,
|
||||||
|
|||||||
@ -160,6 +160,7 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
|||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'display_name' => $displayName,
|
'display_name' => $displayName,
|
||||||
'platform' => $policyPlatform,
|
'platform' => $policyPlatform,
|
||||||
'last_synced_at' => now(),
|
'last_synced_at' => now(),
|
||||||
|
|||||||
@ -54,7 +54,7 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar
|
|||||||
$limited = $sorted->take(50);
|
$limited = $sorted->take(50);
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
$payload = $limited->map(function (array $e) use ($now) {
|
$payload = $limited->map(function (array $e) use ($item, $now) {
|
||||||
$metadata = $e['metadata'] ?? null;
|
$metadata = $e['metadata'] ?? null;
|
||||||
if (is_array($metadata)) {
|
if (is_array($metadata)) {
|
||||||
// Ensure portability across SQLite/Postgres when using upsert via query builder
|
// Ensure portability across SQLite/Postgres when using upsert via query builder
|
||||||
@ -62,6 +62,7 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar
|
|||||||
}
|
}
|
||||||
|
|
||||||
return array_merge($e, [
|
return array_merge($e, [
|
||||||
|
'workspace_id' => $item->workspace_id,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -302,6 +302,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
|
|||||||
'external_id' => $externalId,
|
'external_id' => $externalId,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
'display_name' => $displayName,
|
'display_name' => $displayName,
|
||||||
'category' => $typeConfig['category'] ?? null,
|
'category' => $typeConfig['category'] ?? null,
|
||||||
'platform' => $typeConfig['platform'] ?? null,
|
'platform' => $typeConfig['platform'] ?? null,
|
||||||
|
|||||||
@ -327,7 +327,7 @@ private static function sanitizeNextSteps(array $nextSteps): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
|
$label = self::sanitizeShortString($step['label'] ?? null, fallback: null);
|
||||||
$url = self::sanitizeShortString($step['url'] ?? null, fallback: null);
|
$url = self::sanitizeUrlString($step['url'] ?? null, fallback: null);
|
||||||
|
|
||||||
if ($label === null || $url === null) {
|
if ($label === null || $url === null) {
|
||||||
continue;
|
continue;
|
||||||
@ -342,6 +342,37 @@ private static function sanitizeNextSteps(array $nextSteps): array
|
|||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function sanitizeUrlString(mixed $value, ?string $fallback): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::containsForbiddenKeySubstring($value)) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLs can be long (OAuth consent / remediation links). Truncating too aggressively can
|
||||||
|
// remove required query parameters (e.g. `scope`) and break the remediation flow.
|
||||||
|
$value = substr($value, 0, 2048);
|
||||||
|
|
||||||
|
if (str_starts_with($value, '/')) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($value, 'https://') || str_starts_with($value, 'http://')) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
private static function sanitizeMessage(mixed $message): string
|
private static function sanitizeMessage(mixed $message): string
|
||||||
{
|
{
|
||||||
if (! is_string($message)) {
|
if (! is_string($message)) {
|
||||||
|
|||||||
@ -32,8 +32,16 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]) }}">
|
@php
|
||||||
Zurück zur Tenant-Detailseite
|
$isOnboarding = in_array($tenant->status, [\App\Models\Tenant::STATUS_DRAFT, \App\Models\Tenant::STATUS_ONBOARDING], true);
|
||||||
|
$backUrl = $isOnboarding
|
||||||
|
? route('admin.onboarding')
|
||||||
|
: route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]);
|
||||||
|
$backLabel = $isOnboarding ? 'Zurück zum Onboarding' : 'Zurück zur Tenant-Detailseite';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<a href="{{ $backUrl }}">
|
||||||
|
{{ $backLabel }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('stores successful admin consent on provider connection status', function () {
|
it('stores successful admin consent on provider connection status', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-1',
|
'tenant_id' => 'tenant-1',
|
||||||
'name' => 'Contoso',
|
'name' => 'Contoso',
|
||||||
]);
|
]);
|
||||||
@ -19,6 +20,10 @@
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
|
$response->assertSee(
|
||||||
|
route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
$connection = ProviderConnection::query()
|
$connection = ProviderConnection::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -37,8 +42,29 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates tenant and provider connection when callback tenant does not exist', function () {
|
it('links back to onboarding when tenant is onboarding', function () {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'tenant_id' => 'tenant-3',
|
||||||
|
'name' => 'Onboarding Tenant',
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
]);
|
||||||
|
|
||||||
$response = $this->get(route('admin.consent.callback', [
|
$response = $this->get(route('admin.consent.callback', [
|
||||||
|
'tenant' => $tenant->tenant_id,
|
||||||
|
'admin_consent' => 'true',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee(route('admin.onboarding'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates tenant and provider connection when callback tenant does not exist', function () {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->withSession([
|
||||||
|
'tenant_onboard_workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_onboard_state' => 'state-456',
|
||||||
|
])->get(route('admin.consent.callback', [
|
||||||
'tenant' => 'new-tenant',
|
'tenant' => 'new-tenant',
|
||||||
'state' => 'state-456',
|
'state' => 'state-456',
|
||||||
]));
|
]));
|
||||||
@ -60,7 +86,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('records consent callback errors on provider connection state', function () {
|
it('records consent callback errors on provider connection state', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-2',
|
'tenant_id' => 'tenant-2',
|
||||||
'name' => 'Fabrikam',
|
'name' => 'Fabrikam',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -97,6 +97,25 @@
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('initializes entangled wizard state keys to avoid Livewire entangle errors', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class)
|
||||||
|
->assertSet('data.notes', '')
|
||||||
|
->assertSet('data.override_blocked', false)
|
||||||
|
->assertSet('data.override_reason', '');
|
||||||
|
});
|
||||||
|
|
||||||
it('is idempotent when identifying the same Entra tenant ID twice', function (): void {
|
it('is idempotent when identifying the same Entra tenant ID twice', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSchedule;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\EntraGroup;
|
||||||
|
use App\Models\EntraRoleDefinition;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventoryLink;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantPermission;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Support\WorkspaceIsolation\WorkspaceIsolationViolation;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('enforces tenant workspace binding for tenant-owned models even when events are disabled', function (): void {
|
||||||
|
$workspaceA = Workspace::factory()->create();
|
||||||
|
$workspaceB = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cases = [
|
||||||
|
'policies' => fn (int $workspaceId) => Policy::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]),
|
||||||
|
'policy_versions' => fn (int $workspaceId) => PolicyVersion::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
]),
|
||||||
|
'backup_sets' => fn (int $workspaceId) => BackupSet::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]),
|
||||||
|
'backup_items' => fn (int $workspaceId) => BackupItem::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'backup_set_id' => $backupSet->getKey(),
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
]),
|
||||||
|
'restore_runs' => fn (int $workspaceId) => RestoreRun::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'backup_set_id' => $backupSet->getKey(),
|
||||||
|
]),
|
||||||
|
'backup_schedules' => fn (int $workspaceId) => BackupSchedule::make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'name' => 'Weekly backup',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'time_of_day' => '00:00:00',
|
||||||
|
'days_of_week' => null,
|
||||||
|
'policy_types' => ['settingsCatalogPolicy'],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'retention_keep_last' => 30,
|
||||||
|
]),
|
||||||
|
'inventory_items' => fn (int $workspaceId) => InventoryItem::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]),
|
||||||
|
'inventory_links' => fn (int $workspaceId) => InventoryLink::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]),
|
||||||
|
'entra_groups' => fn (int $workspaceId) => EntraGroup::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]),
|
||||||
|
'findings' => fn (int $workspaceId) => Finding::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]),
|
||||||
|
'entra_role_definitions' => fn (int $workspaceId) => EntraRoleDefinition::factory()->make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]),
|
||||||
|
'tenant_permissions' => fn (int $workspaceId) => TenantPermission::make([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'permission_key' => 'test.permission.'.uniqid(),
|
||||||
|
'status' => 'missing',
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cases as $table => $makeModel) {
|
||||||
|
$model = $makeModel((int) $workspaceA->getKey());
|
||||||
|
|
||||||
|
($model::class)::withoutEvents(function () use ($model): void {
|
||||||
|
$model->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertDatabaseHas($table, [
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $workspaceA->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mismatched = $makeModel((int) $workspaceB->getKey());
|
||||||
|
|
||||||
|
expect(fn () => $mismatched->save())
|
||||||
|
->toThrow(WorkspaceIsolationViolation::class);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
it('includes scope parameter in admin consent url', function () {
|
it('includes scope parameter in admin consent url', function () {
|
||||||
// The adminConsentUrl builds scopes from intune_permissions config, not graph.scope
|
// The adminConsentUrl uses `.default` so the tenant consents to configured app permissions.
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenant_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf',
|
'tenant_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf',
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
@ -17,17 +17,9 @@
|
|||||||
|
|
||||||
$url = TenantResource::adminConsentUrl($tenant);
|
$url = TenantResource::adminConsentUrl($tenant);
|
||||||
|
|
||||||
expect($url)->toContain('scope=');
|
expect($url)
|
||||||
|
->toContain('scope=')
|
||||||
// Should contain permissions from intune_permissions config
|
->toContain(urlencode('https://graph.microsoft.com/.default'));
|
||||||
$requiredPermissions = config('intune_permissions.permissions', []);
|
|
||||||
if (! empty($requiredPermissions)) {
|
|
||||||
$firstPermission = $requiredPermissions[0]['key'];
|
|
||||||
expect($url)->toContain(urlencode("https://graph.microsoft.com/{$firstPermission}"));
|
|
||||||
} else {
|
|
||||||
// Fallback to .default if no permissions configured
|
|
||||||
expect($url)->toContain(urlencode('https://graph.microsoft.com/.default'));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can derive admin consent url from provider connection credentials when tenant app_client_id is missing', function () {
|
it('can derive admin consent url from provider connection credentials when tenant app_client_id is missing', function () {
|
||||||
|
|||||||
36
tests/Unit/VerificationReportSanitizerNextStepsUrlTest.php
Normal file
36
tests/Unit/VerificationReportSanitizerNextStepsUrlTest.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Verification\VerificationReportSanitizer;
|
||||||
|
|
||||||
|
it('does not truncate next step URLs so required OAuth query params remain present', function (): void {
|
||||||
|
$tenantId = 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf';
|
||||||
|
$clientId = 'c9110351-1e46-43fe-865d-8a1ce896cc47';
|
||||||
|
|
||||||
|
$veryLongRedirect = 'http://localhost/admin/consent/callback/'.str_repeat('x', 260);
|
||||||
|
|
||||||
|
$url = sprintf(
|
||||||
|
'https://login.microsoftonline.com/%s/v2.0/adminconsent?%s',
|
||||||
|
$tenantId,
|
||||||
|
http_build_query([
|
||||||
|
'client_id' => $clientId,
|
||||||
|
'state' => 'tenantpilot|123',
|
||||||
|
'redirect_uri' => $veryLongRedirect,
|
||||||
|
'scope' => 'https://graph.microsoft.com/.default',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// This URL exceeds 200 chars; sanitizer must not drop the `scope`.
|
||||||
|
expect(strlen($url))->toBeGreaterThan(200);
|
||||||
|
|
||||||
|
$sanitized = VerificationReportSanitizer::sanitizeNextStepsPayload([
|
||||||
|
['label' => 'Grant admin consent', 'url' => $url],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($sanitized)
|
||||||
|
->toBeArray()
|
||||||
|
->not->toBeEmpty()
|
||||||
|
->and($sanitized[0]['url'] ?? null)->toContain('scope=')
|
||||||
|
->and($sanitized[0]['url'] ?? null)->toContain(urlencode('https://graph.microsoft.com/.default'));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user