fix(onboarding): preserve workspace scope and consent flow #117

Merged
ahmido merged 2 commits from 097-settings-foundation into dev 2026-02-15 22:27:57 +00:00
16 changed files with 286 additions and 32 deletions

View File

@ -506,6 +506,12 @@ private function resumeLatestOnboardingSessionIfUnambiguous(): 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)) {
$this->data['connection_mode'] = 'existing';
}

View File

@ -1167,17 +1167,10 @@ public static function adminConsentUrl(Tenant $tenant): ?string
return null;
}
// Build explicit scope list from required permissions
$requiredPermissions = config('intune_permissions.permissions', []);
$scopes = collect($requiredPermissions)
->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';
}
// Admin consent should use `.default` so the tenant consents to the app's configured
// application permissions. Keeping the URL short also avoids edge cases where a long
// scope string gets truncated and causes AADSTS900144 (missing `scope`).
$scopes = 'https://graph.microsoft.com/.default';
$query = http_build_query([
'client_id' => $clientId,

View File

@ -20,6 +20,7 @@ public function __invoke(
AuditLogger $auditLogger,
): View {
$expectedState = $request->session()->pull('tenant_onboard_state');
$workspaceId = $request->session()->pull('tenant_onboard_workspace_id');
$tenantKey = $request->string('tenant')->toString();
$state = $request->string('state')->toString();
$tenantIdentifier = $tenantKey ?: $this->parseState($state);
@ -30,7 +31,7 @@ public function __invoke(
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;
$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 */
$tenant = Tenant::withTrashed()
@ -87,12 +88,19 @@ private function resolveTenant(string $tenantIdentifier): Tenant
}
if ($tenant instanceof Tenant) {
if ($tenant->workspace_id === null && $workspaceId !== null) {
$tenant->forceFill(['workspace_id' => $workspaceId])->save();
}
return $tenant;
}
abort_if($workspaceId === null, ResponseAlias::HTTP_FORBIDDEN, 'Missing workspace context');
return Tenant::create([
'tenant_id' => $tenantIdentifier,
'name' => 'New Tenant',
'workspace_id' => $workspaceId,
]);
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@ -20,6 +21,12 @@ public function __invoke(Request $request): RedirectResponse
$state = Str::uuid()->toString();
$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([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,

View File

@ -153,6 +153,7 @@ public function sync(Tenant $tenant, string $selectionKey): array
$groupTypes = $item['groupTypes'] ?? null;
$values = [
'workspace_id' => $tenant->workspace_id,
'display_name' => is_string($displayName) ? $displayName : $entraId,
'group_types' => is_array($groupTypes) ? $groupTypes : [],
'security_enabled' => (bool) ($item['securityEnabled'] ?? false),

View File

@ -148,6 +148,7 @@ public function sync(Tenant $tenant): array
$isBuiltIn = (bool) ($item['isBuiltIn'] ?? false);
$values = [
'workspace_id' => $tenant->workspace_id,
'display_name' => is_string($displayName) ? $displayName : $entraId,
'is_built_in' => $isBuiltIn,
'last_seen_at' => $nowUtc,

View File

@ -160,6 +160,7 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
'policy_type' => $policyType,
],
[
'workspace_id' => $tenant->workspace_id,
'display_name' => $displayName,
'platform' => $policyPlatform,
'last_synced_at' => now(),

View File

@ -54,7 +54,7 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar
$limited = $sorted->take(50);
$now = now();
$payload = $limited->map(function (array $e) use ($now) {
$payload = $limited->map(function (array $e) use ($item, $now) {
$metadata = $e['metadata'] ?? null;
if (is_array($metadata)) {
// 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, [
'workspace_id' => $item->workspace_id,
'created_at' => $now,
'updated_at' => $now,
]);

View File

@ -302,6 +302,7 @@ private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $t
'external_id' => $externalId,
],
[
'workspace_id' => $tenant->workspace_id,
'display_name' => $displayName,
'category' => $typeConfig['category'] ?? null,
'platform' => $typeConfig['platform'] ?? null,

View File

@ -327,7 +327,7 @@ private static function sanitizeNextSteps(array $nextSteps): array
}
$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) {
continue;
@ -342,6 +342,37 @@ private static function sanitizeNextSteps(array $nextSteps): array
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
{
if (! is_string($message)) {

View File

@ -32,8 +32,16 @@
@endif
<p>
<a href="{{ route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]) }}">
Zurück zur Tenant-Detailseite
@php
$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>
</p>
</div>

View File

@ -2,13 +2,14 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('stores successful admin consent on provider connection status', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-1',
'name' => 'Contoso',
]);
@ -19,6 +20,10 @@
]));
$response->assertOk();
$response->assertSee(
route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]),
false,
);
$connection = ProviderConnection::query()
->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', [
'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',
'state' => 'state-456',
]));
@ -60,7 +86,7 @@
});
it('records consent callback errors on provider connection state', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-2',
'name' => 'Fabrikam',
]);

View File

@ -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 {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();

View File

@ -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);
}
});

View File

@ -8,7 +8,7 @@
uses(RefreshDatabase::class);
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_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf',
'name' => 'Test Tenant',
@ -17,17 +17,9 @@
$url = TenantResource::adminConsentUrl($tenant);
expect($url)->toContain('scope=');
// Should contain permissions from intune_permissions config
$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'));
}
expect($url)
->toContain('scope=')
->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 () {

View 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'));
});