feat: implement spec 285 workspace-first environment access (#344)
Implements platform feature branch `285-workspace-rbac-environment-access`. Summary: - switch managed environment authorization to workspace-first role resolution with explicit environment-scope narrowing - rewire Filament pages, resources, policies, and user tenant access helpers to the shared access-scope resolver - add Spec 285 coverage across unit, feature, and browser tests plus full spec artifacts Validation: - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Auth/WorkspaceFirstCapabilityResolverTest.php tests/Unit/Auth/ManagedEnvironmentAccessScopeResolverTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Verification/ProviderExecutionReauthorizationTest.php tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php tests/Feature/Tenants/TenantProviderBackedActionStartTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/TenantMembershipAuditLogTest.php tests/Feature/Filament/TenantMembersTest.php tests/Feature/TenantRBAC/TenantMembershipCrudTest.php tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` Target branch: `platform-dev`. Follow-up integration path after merge: - `platform-dev` -> `dev`. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #344
This commit is contained in:
parent
75ebade345
commit
c7b38606a9
@ -651,8 +651,7 @@ private function tenantOptions(): array
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$tenants = $user->tenants()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
$tenants = $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||
->select('managed_environments.*')
|
||||
->orderBy('managed_environments.name')
|
||||
->get();
|
||||
@ -853,4 +852,4 @@ private function workspace(): ?Workspace
|
||||
|
||||
return $workspace instanceof Workspace ? $workspace : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -528,7 +528,7 @@ private function assigneeContext(Finding $record): ?string
|
||||
return 'Soft-deleted user';
|
||||
}
|
||||
|
||||
return 'No current tenant membership';
|
||||
return 'No current environment access';
|
||||
}
|
||||
|
||||
private function tenantFilterAloneExcludesRows(): bool
|
||||
|
||||
@ -421,8 +421,7 @@ private function authorizedTenants(): array
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = $user->tenants()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
return $this->authorizedTenants = $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||
|
||||
@ -370,8 +370,7 @@ private function authorizedTenants(): array
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = $user->tenants()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
return $this->authorizedTenants = $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||
|
||||
@ -517,8 +517,7 @@ private static function resolveWorkspaceFromRequest(): ?Workspace
|
||||
*/
|
||||
private static function resolveAuthorizedTenantsFor(User $user, Workspace $workspace): array
|
||||
{
|
||||
return $user->tenants()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
return $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||
@ -710,4 +709,4 @@ private function appendQuery(string $url, array $query): string
|
||||
|
||||
return $url.$separator.$queryString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -358,8 +358,7 @@ private function authorizedTenants(): array
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = $user->tenants()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
return $this->authorizedTenants = $user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
->get(['managed_environments.id', 'managed_environments.name', 'managed_environments.slug', 'managed_environments.workspace_id'])
|
||||
|
||||
@ -300,8 +300,7 @@ private function accessibleTenants(): array
|
||||
|
||||
$workspaceId = $this->workspaceId();
|
||||
|
||||
return $this->accessibleTenants = $user->tenants()
|
||||
->where('managed_environments.workspace_id', $workspaceId)
|
||||
return $this->accessibleTenants = $user->accessibleManagedEnvironmentsQuery($workspaceId)
|
||||
->orderBy('managed_environments.name')
|
||||
->get()
|
||||
->filter(fn (ManagedEnvironment $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
||||
|
||||
@ -535,8 +535,7 @@ public function authorizedTenants(): array
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
$tenants = $user->tenants()
|
||||
->where('managed_environments.workspace_id', $workspaceId)
|
||||
$tenants = $user->accessibleManagedEnvironmentsQuery($workspaceId)
|
||||
->orderBy('managed_environments.name')
|
||||
->get();
|
||||
|
||||
|
||||
@ -5,9 +5,8 @@
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Forms;
|
||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||
@ -43,21 +42,6 @@ public static function canView(): bool
|
||||
}
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()->withTrashed()->pluck('managed_environments.id');
|
||||
|
||||
if ($tenantIds->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
foreach (ManagedEnvironment::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -108,32 +92,18 @@ protected function handleRegistration(array $data): Model
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => [
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
'created_by_user_id' => $user->getKey(),
|
||||
],
|
||||
]);
|
||||
if ($user instanceof User && is_int($workspaceId)) {
|
||||
$explicitScopes = app(ManagedEnvironmentAccessScopeResolver::class)
|
||||
->allowedManagedEnvironmentIdsForWorkspace($user, $workspaceId);
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant_membership.bootstrap_assign',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
],
|
||||
],
|
||||
actorId: (int) $user->getKey(),
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
if (is_array($explicitScopes)) {
|
||||
app(TenantMembershipManager::class)->grantScope(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
member: $user,
|
||||
source: 'manual',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantDiagnosticsService;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
@ -45,12 +44,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->missingOwner = ! ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
$this->missingOwner = app(TenantDiagnosticsService::class)->tenantHasNoOwners($tenant);
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
@ -81,7 +75,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('mergeDuplicateMemberships')
|
||||
->label('Merge duplicate memberships')
|
||||
->label('Merge duplicate access scopes')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->mergeDuplicateMemberships()),
|
||||
)
|
||||
@ -102,7 +96,7 @@ public function bootstrapOwner(): void
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
app(TenantMembershipManager::class)->bootstrapRecover($tenant, $user, $user);
|
||||
app(TenantMembershipManager::class)->grantScope($tenant, $user, $user, source: 'diagnostic');
|
||||
|
||||
$this->mount();
|
||||
}
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
@ -3409,7 +3408,7 @@ private function resolveWorkspaceIdForUnboundTenant(ManagedEnvironment $tenant):
|
||||
$workspaceId = DB::table('managed_environment_memberships')
|
||||
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'managed_environment_memberships.user_id')
|
||||
->where('managed_environment_memberships.managed_environment_id', (int) $tenant->getKey())
|
||||
->orderByRaw("CASE managed_environment_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
|
||||
->orderBy('workspace_memberships.created_at')
|
||||
->value('workspace_memberships.workspace_id');
|
||||
|
||||
return $workspaceId === null ? null : (int) $workspaceId;
|
||||
@ -3521,23 +3520,13 @@ public function identifyManagedTenant(array $data): void
|
||||
}
|
||||
}
|
||||
|
||||
$membershipManager->addMember(
|
||||
$membershipManager->grantScope(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
member: $user,
|
||||
role: 'owner',
|
||||
source: 'manual',
|
||||
);
|
||||
|
||||
$ownerCount = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->where('role', 'owner')
|
||||
->count();
|
||||
|
||||
if ($ownerCount === 0) {
|
||||
throw new RuntimeException('ManagedEnvironment must have at least one owner.');
|
||||
}
|
||||
|
||||
$this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant);
|
||||
|
||||
$session = $this->mutationService()->createOrResume(
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
@ -58,16 +59,23 @@ public function getTenants(): Collection
|
||||
return ManagedEnvironment::query()->whereRaw('1 = 0')->get();
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenantMemberships()
|
||||
->pluck('managed_environment_id');
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ManagedEnvironment::query()
|
||||
$tenants = ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->whereIn('id', $tenantIds)
|
||||
->where('workspace_id', $this->workspace->getKey())
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(function (ManagedEnvironment $tenant) use ($user): bool {
|
||||
->get();
|
||||
|
||||
$resolver->primeMemberships($user, $tenants->modelKeys());
|
||||
|
||||
return $tenants
|
||||
->filter(function (ManagedEnvironment $tenant) use ($resolver, $user): bool {
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
@ -116,6 +117,8 @@ public static function getEloquentQuery(): Builder
|
||||
$user = auth()->user();
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['tenant', 'rule', 'destination'])
|
||||
->when(
|
||||
@ -132,9 +135,22 @@ public static function getEloquentQuery(): Builder
|
||||
)
|
||||
->when(
|
||||
$user instanceof User,
|
||||
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void {
|
||||
$q->whereIn('managed_environment_id', $user->tenantMemberships()->select('managed_environment_id'))
|
||||
->orWhereNull('managed_environment_id');
|
||||
fn (Builder $query): Builder => $query->where(function (Builder $q) use ($scopeResolver, $user, $workspaceId): void {
|
||||
$q->whereNull('managed_environment_id')
|
||||
->orWhere(function (Builder $scopedQuery) use ($scopeResolver, $user, $workspaceId): void {
|
||||
if (! is_int($workspaceId)) {
|
||||
$scopedQuery->whereRaw('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scopeResolver->applyWorkspaceScopeToQuery(
|
||||
query: $scopedQuery,
|
||||
user: $user,
|
||||
workspaceId: $workspaceId,
|
||||
qualifiedEnvironmentColumn: 'alert_deliveries.managed_environment_id',
|
||||
);
|
||||
});
|
||||
}),
|
||||
)
|
||||
->when(
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
@ -534,12 +535,18 @@ private static function tenantMemberOptions(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return \App\Models\ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'managed_environment_memberships.user_id')
|
||||
->orderBy('users.name')
|
||||
->pluck('users.name', 'users.id')
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
||||
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||
|
||||
return User::query()
|
||||
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||||
->orderBy('name')
|
||||
->orderBy('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->filter(fn (User $user): bool => $scopeResolver->canAccess($user, $tenant))
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(int) $user->id => trim((string) ($user->name ?: $user->email)),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\FindingException;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
@ -20,6 +21,7 @@
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use InvalidArgumentException;
|
||||
|
||||
@ -202,12 +204,18 @@ private function tenantMemberOptions(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return \App\Models\ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'managed_environment_memberships.user_id')
|
||||
->orderBy('users.name')
|
||||
->pluck('users.name', 'users.id')
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
||||
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||
|
||||
return User::query()
|
||||
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||||
->orderBy('name')
|
||||
->orderBy('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->filter(fn (User $user): bool => $scopeResolver->canAccess($user, $tenant))
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(int) $user->id => trim((string) ($user->name ?: $user->email)),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
use App\Models\FindingException;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
@ -2453,12 +2453,18 @@ private static function tenantMemberOptions(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->join('users', 'users.id', '=', 'managed_environment_memberships.user_id')
|
||||
->orderBy('users.name')
|
||||
->pluck('users.name', 'users.id')
|
||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
||||
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||
|
||||
return User::query()
|
||||
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||||
->orderBy('name')
|
||||
->orderBy('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->filter(fn (User $user): bool => $scopeResolver->canAccess($user, $tenant))
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(int) $user->id => trim((string) ($user->name ?: $user->email)),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Providers\ProviderConnectionMutationService;
|
||||
use App\Services\Providers\ProviderOperationStartGate;
|
||||
@ -51,7 +52,6 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use UnitEnum;
|
||||
@ -349,19 +349,16 @@ private static function applyMembershipScope(Builder $query): Builder
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query
|
||||
->where('provider_connections.workspace_id', $workspaceId)
|
||||
->whereExists(function ($membershipScope) use ($user, $workspaceId): void {
|
||||
$membershipScope
|
||||
->selectRaw('1')
|
||||
->from('managed_environments as scoped_tenants')
|
||||
->join('managed_environment_memberships as scoped_memberships', function (JoinClause $join) use ($user): void {
|
||||
$join->on('scoped_memberships.managed_environment_id', '=', 'scoped_tenants.id')
|
||||
->where('scoped_memberships.user_id', '=', (int) $user->getKey());
|
||||
})
|
||||
->whereColumn('scoped_tenants.id', 'provider_connections.managed_environment_id')
|
||||
->where('scoped_tenants.workspace_id', '=', $workspaceId);
|
||||
});
|
||||
$query->where('provider_connections.workspace_id', $workspaceId);
|
||||
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->applyWorkspaceScopeToQuery(
|
||||
query: $query,
|
||||
user: $user,
|
||||
workspaceId: $workspaceId,
|
||||
qualifiedEnvironmentColumn: 'provider_connections.managed_environment_id',
|
||||
);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -376,13 +373,18 @@ private static function tenantFilterOptions(): array
|
||||
return [];
|
||||
}
|
||||
|
||||
return ManagedEnvironment::query()
|
||||
$query = ManagedEnvironment::query()
|
||||
->select(['managed_environments.slug', 'managed_environments.name', 'managed_environments.kind'])
|
||||
->join('managed_environment_memberships as filter_memberships', function (JoinClause $join) use ($user): void {
|
||||
$join->on('filter_memberships.managed_environment_id', '=', 'managed_environments.id')
|
||||
->where('filter_memberships.user_id', '=', (int) $user->getKey());
|
||||
})
|
||||
->where('managed_environments.workspace_id', $workspaceId)
|
||||
->where('managed_environments.workspace_id', $workspaceId);
|
||||
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->applyWorkspaceScopeToQuery(
|
||||
query: $query,
|
||||
user: $user,
|
||||
workspaceId: $workspaceId,
|
||||
qualifiedEnvironmentColumn: 'managed_environments.id',
|
||||
);
|
||||
|
||||
return $query
|
||||
->orderBy('managed_environments.name')
|
||||
->get()
|
||||
->mapWithKeys(function (ManagedEnvironment $tenant): array {
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Directory\RoleDefinitionsSyncService;
|
||||
@ -225,12 +224,12 @@ public static function makeMembershipsAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('memberships')
|
||||
->label('Manage memberships')
|
||||
->label('Manage access scope')
|
||||
->icon('heroicon-o-users')
|
||||
->url(fn (ManagedEnvironment $record): string => static::getUrl('memberships', ['record' => $record], panel: 'admin')),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_VIEW)
|
||||
->tooltip('You do not have permission to view tenant memberships.')
|
||||
->tooltip('You do not have permission to view environment access scopes.')
|
||||
->preserveVisibility()
|
||||
->apply();
|
||||
}
|
||||
@ -682,16 +681,29 @@ private static function handleVerifyConfigurationAction(
|
||||
|
||||
private static function userCanManageAnyTenant(User $user): bool
|
||||
{
|
||||
return $user->tenantMemberships()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
||||
return static::userHasCurrentWorkspaceCapability($user, Capabilities::TENANT_MANAGE);
|
||||
}
|
||||
|
||||
private static function userCanDeleteAnyTenant(User $user): bool
|
||||
{
|
||||
return $user->tenantMemberships()
|
||||
->pluck('role')
|
||||
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
|
||||
return static::userHasCurrentWorkspaceCapability($user, Capabilities::TENANT_DELETE);
|
||||
}
|
||||
|
||||
private static function userHasCurrentWorkspaceCapability(User $user, string $capability): bool
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(WorkspaceCapabilityResolver::class)->can($user, $workspace, $capability);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -741,9 +753,8 @@ public static function getEloquentQuery(): Builder
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$tenantIds = $user->tenants()
|
||||
$tenantIds = $user->accessibleManagedEnvironmentsQuery($workspaceId)
|
||||
->withTrashed()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->pluck('managed_environments.id');
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
|
||||
@ -7,11 +7,11 @@
|
||||
|
||||
class ManageTenantMemberships extends ViewTenant
|
||||
{
|
||||
protected static ?string $title = 'Manage tenant memberships';
|
||||
protected static ?string $title = 'Manage environment access scope';
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return 'ManagedEnvironment access is managed here. Use the tenant overview for provider state, verification, and operational context.';
|
||||
return 'Workspace membership defines the role. Explicit environment scopes only narrow which workspace members can see this environment.';
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
@ -29,7 +29,7 @@ protected function getHeaderActions(): array
|
||||
array_unshift(
|
||||
$actions,
|
||||
Action::make('back_to_overview')
|
||||
->label('Back to tenant overview')
|
||||
->label('Back to environment overview')
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $this->getRecord()->getRouteKey()], panel: 'admin')),
|
||||
);
|
||||
|
||||
@ -29,11 +29,11 @@ class TenantMembershipsRelationManager extends RelationManager
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'ManagedEnvironment membership rows are managed inline and have no separate inspect destination.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Change role and remove stay direct for focused inline membership management.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Add explicit access scope action is available in the relation header.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'ManagedEnvironment access scope rows are managed inline and have no separate inspect destination.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Remove stays direct for focused inline scope management.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk access scope mutations are intentionally omitted.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add explicit access scope remains available in the header.');
|
||||
}
|
||||
|
||||
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
|
||||
@ -86,9 +86,6 @@ public function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label(__('Name'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('role')
|
||||
->badge()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('source')
|
||||
->badge()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
@ -97,29 +94,14 @@ public function table(Table $table): Table
|
||||
->headerActions([
|
||||
UiEnforcement::forTableAction(
|
||||
Action::make('add_member')
|
||||
->label(__('Add member'))
|
||||
->label(__('Add explicit access scope'))
|
||||
->icon('heroicon-o-plus')
|
||||
->form([
|
||||
Forms\Components\Select::make('user_id')
|
||||
->label(__('User'))
|
||||
->label(__('Workspace member'))
|
||||
->required()
|
||||
->searchable()
|
||||
->options(fn () => User::query()
|
||||
->orderBy('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||
])
|
||||
->all()),
|
||||
Forms\Components\Select::make('role')
|
||||
->label(__('Role'))
|
||||
->required()
|
||||
->options([
|
||||
'owner' => __('Owner'),
|
||||
'manager' => __('Manager'),
|
||||
'operator' => __('Operator'),
|
||||
'readonly' => __('Readonly'),
|
||||
]),
|
||||
->options(fn (): array => $this->workspaceMemberOptions()),
|
||||
])
|
||||
->action(function (array $data, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
@ -141,16 +123,15 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$manager->addMember(
|
||||
$manager->grantScope(
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
member: $member,
|
||||
role: (string) $data['role'],
|
||||
source: 'manual',
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title(__('Failed to add member'))
|
||||
->title(__('Failed to add explicit access scope'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
@ -158,73 +139,19 @@ public function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title(__('Member added'))->success()->send();
|
||||
Notification::make()->title(__('Explicit access scope added'))->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
fn () => $this->getOwnerRecord(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage tenant memberships.')
|
||||
->tooltip('You do not have permission to manage environment access scopes.')
|
||||
->apply(),
|
||||
])
|
||||
->actions([
|
||||
UiEnforcement::forTableAction(
|
||||
Action::make('change_role')
|
||||
->label(__('Change role'))
|
||||
->icon('heroicon-o-pencil')
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Forms\Components\Select::make('role')
|
||||
->label(__('Role'))
|
||||
->required()
|
||||
->options([
|
||||
'owner' => __('Owner'),
|
||||
'manager' => __('Manager'),
|
||||
'operator' => __('Operator'),
|
||||
'readonly' => __('Readonly'),
|
||||
]),
|
||||
])
|
||||
->action(function (ManagedEnvironmentMembership $record, array $data, TenantMembershipManager $manager): void {
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
if (! $actor instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$manager->changeRole(
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
membership: $record,
|
||||
newRole: (string) $data['role'],
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title(__('Failed to change role'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title(__('Role updated'))->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
fn () => $this->getOwnerRecord(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage tenant memberships.')
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forTableAction(
|
||||
Action::make('remove')
|
||||
->label(__('Remove'))
|
||||
->label(__('Remove explicit scope'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
@ -244,7 +171,7 @@ public function table(Table $table): Table
|
||||
$manager->removeMember($tenant, $actor, $record);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title(__('Failed to remove member'))
|
||||
->title(__('Failed to remove explicit access scope'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
@ -252,18 +179,40 @@ public function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title(__('Member removed'))->success()->send();
|
||||
Notification::make()->title(__('Explicit access scope removed'))->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
fn () => $this->getOwnerRecord(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage tenant memberships.')
|
||||
->tooltip('You do not have permission to manage environment access scopes.')
|
||||
->destructive()
|
||||
->apply(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('No tenant members'))
|
||||
->emptyStateDescription(__('Add a member to delegate access inside this tenant.'));
|
||||
->emptyStateHeading(__('No explicit access scopes'))
|
||||
->emptyStateDescription(__('Workspace members inherit access unless explicit scopes narrow that member to selected environments.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function workspaceMemberOptions(): array
|
||||
{
|
||||
$tenant = $this->getOwnerRecord();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! is_numeric($tenant->workspace_id)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return User::query()
|
||||
->whereHas('workspaceMemberships', fn (Builder $query): Builder => $query->where('workspace_id', (int) $tenant->workspace_id))
|
||||
->whereDoesntHave('tenantMemberships', fn (Builder $query): Builder => $query->where('managed_environment_id', (int) $tenant->getKey()))
|
||||
->orderBy('email')
|
||||
->get(['id', 'name', 'email'])
|
||||
->mapWithKeys(fn (User $user): array => [
|
||||
(string) $user->id => trim((string) ($user->name ? "{$user->name} ({$user->email})" : $user->email)),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
@ -93,8 +94,14 @@ protected function getStats(): array
|
||||
private function deliveriesQueryForViewer(User $user, int $workspaceId): Builder
|
||||
{
|
||||
$query = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereIn('managed_environment_id', $user->tenantMemberships()->select('managed_environment_id'));
|
||||
->where('workspace_id', $workspaceId);
|
||||
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->applyWorkspaceScopeToQuery(
|
||||
query: $query,
|
||||
user: $user,
|
||||
workspaceId: $workspaceId,
|
||||
qualifiedEnvironmentColumn: 'alert_deliveries.managed_environment_id',
|
||||
);
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -11,6 +12,7 @@
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@ -111,13 +113,6 @@ public function tenantPreferences(): HasMany
|
||||
return $this->hasMany(UserTenantPreference::class);
|
||||
}
|
||||
|
||||
private function tenantPivotTableExists(): bool
|
||||
{
|
||||
static $exists;
|
||||
|
||||
return $exists ??= Schema::hasTable('managed_environment_memberships');
|
||||
}
|
||||
|
||||
private function tenantPreferencesTableExists(): bool
|
||||
{
|
||||
static $exists;
|
||||
@ -127,10 +122,6 @@ private function tenantPreferencesTableExists(): bool
|
||||
|
||||
public function tenantRoleValue(ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
if (! $this->tenantPivotTableExists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
@ -148,39 +139,59 @@ public function canAccessTenant(Model $tenant): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->tenantPivotTableExists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($this, $tenant);
|
||||
}
|
||||
|
||||
public function accessibleManagedEnvironmentsQuery(int $workspaceId): Builder
|
||||
{
|
||||
$query = ManagedEnvironment::query()
|
||||
->where('managed_environments.workspace_id', $workspaceId);
|
||||
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->applyWorkspaceScopeToQuery(
|
||||
query: $query,
|
||||
user: $this,
|
||||
workspaceId: $workspaceId,
|
||||
qualifiedEnvironmentColumn: 'managed_environments.id',
|
||||
);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function getTenants(Panel $panel): array|Collection
|
||||
{
|
||||
if (! $this->tenantPivotTableExists()) {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
$operability = app(TenantOperabilityService::class);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $operability->filterSelectable($this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('managed_environments.workspace_id', $workspaceId))
|
||||
$tenants = $this->accessibleManagedEnvironmentsQuery($workspaceId)
|
||||
->orderBy('name')
|
||||
->get());
|
||||
->get();
|
||||
|
||||
$resolver->primeMemberships($this, $tenants->modelKeys());
|
||||
|
||||
return $operability->filterSelectable($tenants
|
||||
->filter(fn (ManagedEnvironment $tenant): bool => $resolver->isMember($this, $tenant))
|
||||
->values());
|
||||
}
|
||||
|
||||
public function getDefaultTenant(Panel $panel): ?Model
|
||||
{
|
||||
if (! $this->tenantPivotTableExists()) {
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId();
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspaceId = $workspaceContext->currentWorkspaceId();
|
||||
$operability = app(TenantOperabilityService::class);
|
||||
|
||||
$rememberedTenant = $workspaceContext->rememberedTenant(request());
|
||||
@ -199,20 +210,17 @@ public function getDefaultTenant(Panel $panel): ?Model
|
||||
}
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$tenant = $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('managed_environments.workspace_id', $workspaceId))
|
||||
$tenant = ManagedEnvironment::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereKey($tenantId)
|
||||
->first();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment && $operability->canSelectAsContext($tenant)) {
|
||||
if ($tenant instanceof ManagedEnvironment && $this->canAccessTenant($tenant) && $operability->canSelectAsContext($tenant)) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->tenants()
|
||||
->when($workspaceId !== null, fn ($query) => $query->where('managed_environments.workspace_id', $workspaceId))
|
||||
->orderBy('name')
|
||||
->get()
|
||||
return $this->getTenants($panel)
|
||||
->first(fn (ManagedEnvironment $tenant): bool => $operability->canSelectAsContext($tenant));
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class EvidenceSnapshotPolicy
|
||||
{
|
||||
@ -26,19 +27,21 @@ public function viewAny(User $user): bool
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, EvidenceSnapshot $snapshot): bool
|
||||
public function view(User $user, EvidenceSnapshot $snapshot): Response|bool
|
||||
{
|
||||
$tenant = ManagedEnvironment::current();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $snapshot->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
@ -52,18 +55,20 @@ public function create(User $user): bool
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, EvidenceSnapshot $snapshot): bool
|
||||
public function delete(User $user, EvidenceSnapshot $snapshot): Response|bool
|
||||
{
|
||||
$tenant = ManagedEnvironment::current();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $snapshot->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
@ -51,9 +52,13 @@ public function view(User $user, OperationRun $run): Response|bool
|
||||
$tenantId = (int) ($run->managed_environment_id ?? 0);
|
||||
|
||||
if ($tenantId > 0) {
|
||||
$hasTenantEntitlement = $user->tenantMemberships()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->exists();
|
||||
$tenant = ManagedEnvironment::query()->withTrashed()->whereKey($tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || (int) $tenant->workspace_id !== $workspaceId) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$hasTenantEntitlement = app(ManagedEnvironmentAccessScopeResolver::class)->canAccess($user, $tenant);
|
||||
|
||||
if (! $hasTenantEntitlement) {
|
||||
return Response::denyAsNotFound();
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -26,14 +26,20 @@ public function viewAny(User $user): Response|bool
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$entitledTenants = ManagedEnvironment::query()
|
||||
->select('managed_environments.*')
|
||||
->join('managed_environment_memberships as policy_memberships', function ($join) use ($user): void {
|
||||
$join->on('policy_memberships.managed_environment_id', '=', 'managed_environments.id')
|
||||
->where('policy_memberships.user_id', '=', (int) $user->getKey());
|
||||
})
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
->get();
|
||||
/** @var ManagedEnvironmentAccessScopeResolver $scopeResolver */
|
||||
$scopeResolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||
|
||||
$entitledTenantsQuery = ManagedEnvironment::query()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey());
|
||||
|
||||
$scopeResolver->applyWorkspaceScopeToQuery(
|
||||
query: $entitledTenantsQuery,
|
||||
user: $user,
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
qualifiedEnvironmentColumn: 'managed_environments.id',
|
||||
);
|
||||
|
||||
$entitledTenants = $entitledTenantsQuery->get();
|
||||
|
||||
if ($entitledTenants->isEmpty()) {
|
||||
return true;
|
||||
@ -273,9 +279,6 @@ private function tenantForConnection(ProviderConnection $connection): ?ManagedEn
|
||||
|
||||
private function isTenantMember(User $user, ManagedEnvironment $tenant): bool
|
||||
{
|
||||
return ManagedEnvironmentMembership::query()
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->exists();
|
||||
return app(ManagedEnvironmentAccessScopeResolver::class)->canAccess($user, $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ReviewPackPolicy
|
||||
{
|
||||
@ -33,26 +34,28 @@ public function viewAny(User $user): bool
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, ReviewPack $reviewPack): bool
|
||||
public function view(User $user, ReviewPack $reviewPack): Response|bool
|
||||
{
|
||||
$tenant = ManagedEnvironment::current();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $reviewPack->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_VIEW);
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_VIEW)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
@ -73,25 +76,27 @@ public function create(User $user): bool
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, ReviewPack $reviewPack): bool
|
||||
public function delete(User $user, ReviewPack $reviewPack): Response|bool
|
||||
{
|
||||
$tenant = ManagedEnvironment::current();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if ((int) $reviewPack->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_MANAGE);
|
||||
return $resolver->can($user, $tenant, Capabilities::REVIEW_PACK_MANAGE)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,6 +99,14 @@ private function authorizeForDraft(
|
||||
$tenant = $tenantOnboardingSession->tenant;
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$viewability = app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
use App\Policies\FindingPolicy;
|
||||
use App\Policies\OperationRunPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
|
||||
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
|
||||
@ -81,6 +82,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->scoped(CapabilityResolver::class);
|
||||
$this->app->scoped(ManagedEnvironmentAccessScopeResolver::class);
|
||||
$this->app->scoped(WorkspaceCapabilityResolver::class);
|
||||
|
||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||
|
||||
@ -3,10 +3,9 @@
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\TenantRole;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@ -17,22 +16,24 @@
|
||||
*/
|
||||
class CapabilityResolver
|
||||
{
|
||||
private array $resolvedMemberships = [];
|
||||
|
||||
private array $loggedDenials = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ManagedEnvironmentAccessScopeResolver $accessScopeResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the user's role for a tenant
|
||||
*/
|
||||
public function getRole(User $user, ManagedEnvironment $tenant): ?TenantRole
|
||||
public function getRole(User $user, ManagedEnvironment $tenant): ?WorkspaceRole
|
||||
{
|
||||
$membership = $this->getMembership($user, $tenant);
|
||||
$decision = $this->accessScopeResolver->decision($user, $tenant);
|
||||
|
||||
if ($membership === null) {
|
||||
if (! $decision->workspaceMember || $decision->workspaceRole === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TenantRole::tryFrom($membership['role']);
|
||||
return WorkspaceRole::tryFrom($decision->workspaceRole);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -44,27 +45,25 @@ public function can(User $user, ManagedEnvironment $tenant, string $capability):
|
||||
throw new \InvalidArgumentException("Unknown capability: {$capability}");
|
||||
}
|
||||
|
||||
$role = $this->getRole($user, $tenant);
|
||||
$decision = $this->accessScopeResolver->decision($user, $tenant, $capability);
|
||||
|
||||
if ($role === null) {
|
||||
$this->logDenial($user, $tenant, $capability);
|
||||
if (! $decision->workspaceMember || ! $decision->managedEnvironmentAllowed) {
|
||||
$this->logDenial($user, $tenant, $capability, $decision->failedBoundary);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isLocallyDeniedByBackupHealthBrowserFixture($user, $tenant, $capability)) {
|
||||
$this->logDenial($user, $tenant, $capability);
|
||||
$this->logDenial($user, $tenant, $capability, 'capability');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = RoleCapabilityMap::hasCapability($role, $capability);
|
||||
|
||||
if (! $allowed) {
|
||||
$this->logDenial($user, $tenant, $capability);
|
||||
if (! $decision->capabilityAllowed) {
|
||||
$this->logDenial($user, $tenant, $capability, $decision->failedBoundary);
|
||||
}
|
||||
|
||||
return $allowed;
|
||||
return $decision->capabilityAllowed;
|
||||
}
|
||||
|
||||
private function isLocallyDeniedByBackupHealthBrowserFixture(User $user, ManagedEnvironment $tenant, string $capability): bool
|
||||
@ -100,7 +99,7 @@ private function isLocallyDeniedByBackupHealthBrowserFixture(User $user, Managed
|
||||
return in_array($capability, $deniedCapabilities, true);
|
||||
}
|
||||
|
||||
private function logDenial(User $user, ManagedEnvironment $tenant, string $capability): void
|
||||
private function logDenial(User $user, ManagedEnvironment $tenant, string $capability, ?string $failedBoundary = null): void
|
||||
{
|
||||
$key = implode(':', [(string) $user->getKey(), (string) $tenant->getKey(), $capability]);
|
||||
|
||||
@ -112,6 +111,8 @@ private function logDenial(User $user, ManagedEnvironment $tenant, string $capab
|
||||
|
||||
Log::warning('rbac.denied', [
|
||||
'capability' => $capability,
|
||||
'failed_boundary' => $failedBoundary,
|
||||
'workspace_id' => is_numeric($tenant->workspace_id) ? (int) $tenant->workspace_id : null,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
@ -122,30 +123,11 @@ private function logDenial(User $user, ManagedEnvironment $tenant, string $capab
|
||||
*/
|
||||
public function isMember(User $user, ManagedEnvironment $tenant): bool
|
||||
{
|
||||
return $this->getMembership($user, $tenant) !== null;
|
||||
return $this->accessScopeResolver->canAccess($user, $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get membership details (cached per request)
|
||||
*/
|
||||
private function getMembership(User $user, ManagedEnvironment $tenant): ?array
|
||||
{
|
||||
$cacheKey = "membership_{$user->id}_{$tenant->id}";
|
||||
|
||||
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
|
||||
$membership = ManagedEnvironmentMembership::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->first(['role', 'source', 'source_ref']);
|
||||
|
||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||
}
|
||||
|
||||
return $this->resolvedMemberships[$cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prime membership cache for a set of tenants in one query.
|
||||
* Prime workspace membership and managed-environment scope cache for a set of tenants.
|
||||
*
|
||||
* Used to avoid N+1 queries for bulk selection authorization while still
|
||||
* reflecting membership changes that may have happened earlier in the same
|
||||
@ -155,24 +137,7 @@ private function getMembership(User $user, ManagedEnvironment $tenant): ?array
|
||||
*/
|
||||
public function primeMemberships(User $user, array $tenantIds): void
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map(static fn ($id): int => (int) $id, $tenantIds)));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$memberships = ManagedEnvironmentMembership::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('managed_environment_id', $tenantIds)
|
||||
->get(['managed_environment_id', 'role', 'source', 'source_ref']);
|
||||
|
||||
$byTenantId = $memberships->keyBy('managed_environment_id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
||||
$membership = $byTenantId->get($tenantId);
|
||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||
}
|
||||
$this->accessScopeResolver->prime($user, $tenantIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -180,6 +145,6 @@ public function primeMemberships(User $user, array $tenantIds): void
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->resolvedMemberships = [];
|
||||
$this->accessScopeResolver->clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
final readonly class ManagedEnvironmentAccessDecision
|
||||
{
|
||||
public function __construct(
|
||||
public int $workspaceId,
|
||||
public int $managedEnvironmentId,
|
||||
public int $userId,
|
||||
public bool $workspaceMember,
|
||||
public ?string $workspaceRole,
|
||||
public bool $explicitScopeRowsPresent,
|
||||
public bool $managedEnvironmentAllowed,
|
||||
public ?string $failedBoundary = null,
|
||||
public ?string $requiredCapability = null,
|
||||
public bool $capabilityAllowed = true,
|
||||
public ?int $denialHttpStatus = null,
|
||||
) {}
|
||||
|
||||
public function allowed(): bool
|
||||
{
|
||||
return $this->workspaceMember
|
||||
&& $this->managedEnvironmentAllowed
|
||||
&& $this->capabilityAllowed;
|
||||
}
|
||||
|
||||
public function shouldDenyAsNotFound(): bool
|
||||
{
|
||||
return $this->denialHttpStatus === 404;
|
||||
}
|
||||
|
||||
public function shouldDenyAsForbidden(): bool
|
||||
{
|
||||
return $this->denialHttpStatus === 403;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class ManagedEnvironmentAccessScopeResolver
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<int, true>|null>
|
||||
*/
|
||||
private array $scopeIdsByUserWorkspace = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
) {}
|
||||
|
||||
public function decision(User $user, ManagedEnvironment $tenant, ?string $requiredCapability = null): ManagedEnvironmentAccessDecision
|
||||
{
|
||||
$tenant = $this->hydrateTenantBoundary($tenant);
|
||||
$workspaceId = (int) ($tenant?->workspace_id ?? 0);
|
||||
$tenantId = (int) ($tenant?->getKey() ?? 0);
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || $workspaceId <= 0 || $tenantId <= 0) {
|
||||
return new ManagedEnvironmentAccessDecision(
|
||||
workspaceId: $workspaceId,
|
||||
managedEnvironmentId: $tenantId,
|
||||
userId: (int) $user->getKey(),
|
||||
workspaceMember: false,
|
||||
workspaceRole: null,
|
||||
explicitScopeRowsPresent: false,
|
||||
managedEnvironmentAllowed: false,
|
||||
failedBoundary: 'managed_environment_scope',
|
||||
requiredCapability: $requiredCapability,
|
||||
capabilityAllowed: false,
|
||||
denialHttpStatus: 404,
|
||||
);
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return new ManagedEnvironmentAccessDecision(
|
||||
workspaceId: $workspaceId,
|
||||
managedEnvironmentId: $tenantId,
|
||||
userId: (int) $user->getKey(),
|
||||
workspaceMember: false,
|
||||
workspaceRole: null,
|
||||
explicitScopeRowsPresent: false,
|
||||
managedEnvironmentAllowed: false,
|
||||
failedBoundary: 'workspace_membership',
|
||||
requiredCapability: $requiredCapability,
|
||||
capabilityAllowed: false,
|
||||
denialHttpStatus: 404,
|
||||
);
|
||||
}
|
||||
|
||||
$workspaceRole = $this->workspaceCapabilityResolver->getRole($user, $workspace);
|
||||
|
||||
if ($workspaceRole === null) {
|
||||
return new ManagedEnvironmentAccessDecision(
|
||||
workspaceId: $workspaceId,
|
||||
managedEnvironmentId: $tenantId,
|
||||
userId: (int) $user->getKey(),
|
||||
workspaceMember: false,
|
||||
workspaceRole: null,
|
||||
explicitScopeRowsPresent: false,
|
||||
managedEnvironmentAllowed: false,
|
||||
failedBoundary: 'workspace_membership',
|
||||
requiredCapability: $requiredCapability,
|
||||
capabilityAllowed: false,
|
||||
denialHttpStatus: 404,
|
||||
);
|
||||
}
|
||||
|
||||
$scopeIds = $this->scopeIdsForWorkspace($user, $workspaceId);
|
||||
$explicitScopeRowsPresent = $scopeIds !== null;
|
||||
$managedEnvironmentAllowed = $scopeIds === null || isset($scopeIds[$tenantId]);
|
||||
|
||||
if (! $managedEnvironmentAllowed) {
|
||||
return new ManagedEnvironmentAccessDecision(
|
||||
workspaceId: $workspaceId,
|
||||
managedEnvironmentId: $tenantId,
|
||||
userId: (int) $user->getKey(),
|
||||
workspaceMember: true,
|
||||
workspaceRole: $workspaceRole->value,
|
||||
explicitScopeRowsPresent: true,
|
||||
managedEnvironmentAllowed: false,
|
||||
failedBoundary: 'managed_environment_scope',
|
||||
requiredCapability: $requiredCapability,
|
||||
capabilityAllowed: false,
|
||||
denialHttpStatus: 404,
|
||||
);
|
||||
}
|
||||
|
||||
$capabilityAllowed = true;
|
||||
|
||||
if (is_string($requiredCapability) && $requiredCapability !== '') {
|
||||
if (! Capabilities::isKnown($requiredCapability)) {
|
||||
throw new \InvalidArgumentException("Unknown capability: {$requiredCapability}");
|
||||
}
|
||||
|
||||
$capabilityAllowed = WorkspaceRoleCapabilityMap::hasCapability($workspaceRole, $requiredCapability);
|
||||
}
|
||||
|
||||
return new ManagedEnvironmentAccessDecision(
|
||||
workspaceId: $workspaceId,
|
||||
managedEnvironmentId: $tenantId,
|
||||
userId: (int) $user->getKey(),
|
||||
workspaceMember: true,
|
||||
workspaceRole: $workspaceRole->value,
|
||||
explicitScopeRowsPresent: $explicitScopeRowsPresent,
|
||||
managedEnvironmentAllowed: true,
|
||||
failedBoundary: $capabilityAllowed ? null : 'capability',
|
||||
requiredCapability: $requiredCapability,
|
||||
capabilityAllowed: $capabilityAllowed,
|
||||
denialHttpStatus: $capabilityAllowed ? null : 403,
|
||||
);
|
||||
}
|
||||
|
||||
public function canAccess(User $user, ManagedEnvironment $tenant): bool
|
||||
{
|
||||
$decision = $this->decision($user, $tenant);
|
||||
|
||||
return $decision->workspaceMember && $decision->managedEnvironmentAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when access is inherited across all environments in the workspace.
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
public function allowedManagedEnvironmentIdsForWorkspace(User $user, int $workspaceId): ?array
|
||||
{
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace || $this->workspaceCapabilityResolver->getRole($user, $workspace) === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scopeIds = $this->scopeIdsForWorkspace($user, $workspaceId);
|
||||
|
||||
if ($scopeIds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_map('intval', array_keys($scopeIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string> $tenantIds
|
||||
*/
|
||||
public function prime(User $user, array $tenantIds): void
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map(static fn ($id): int => (int) $id, $tenantIds)));
|
||||
|
||||
if ($tenantIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenants = ManagedEnvironment::query()
|
||||
->whereIn('id', $tenantIds)
|
||||
->get(['id', 'workspace_id']);
|
||||
|
||||
$workspaceIds = $tenants
|
||||
->pluck('workspace_id')
|
||||
->filter(fn ($id): bool => is_numeric($id))
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->workspaceCapabilityResolver->primeMemberships($user, $workspaceIds);
|
||||
|
||||
foreach ($workspaceIds as $workspaceId) {
|
||||
$this->scopeIdsForWorkspace($user, $workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->scopeIdsByUserWorkspace = [];
|
||||
}
|
||||
|
||||
public function applyWorkspaceScopeToQuery(Builder $query, User $user, int $workspaceId, string $qualifiedEnvironmentColumn): Builder
|
||||
{
|
||||
$allowedIds = $this->allowedManagedEnvironmentIdsForWorkspace($user, $workspaceId);
|
||||
|
||||
if ($allowedIds === []) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
if ($allowedIds === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereIn($qualifiedEnvironmentColumn, $allowedIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Null means inherited access; an array means explicit allowlist.
|
||||
*
|
||||
* @return array<int, true>|null
|
||||
*/
|
||||
private function scopeIdsForWorkspace(User $user, int $workspaceId): ?array
|
||||
{
|
||||
$cacheKey = $this->scopeCacheKey($user, $workspaceId);
|
||||
|
||||
if (array_key_exists($cacheKey, $this->scopeIdsByUserWorkspace)) {
|
||||
return $this->scopeIdsByUserWorkspace[$cacheKey];
|
||||
}
|
||||
|
||||
$scopeIds = ManagedEnvironmentMembership::query()
|
||||
->join('managed_environments', 'managed_environments.id', '=', 'managed_environment_memberships.managed_environment_id')
|
||||
->where('managed_environment_memberships.user_id', (int) $user->getKey())
|
||||
->where('managed_environments.workspace_id', $workspaceId)
|
||||
->pluck('managed_environment_memberships.managed_environment_id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$this->scopeIdsByUserWorkspace[$cacheKey] = $scopeIds->isEmpty()
|
||||
? null
|
||||
: $scopeIds->mapWithKeys(fn (int $id): array => [$id => true])->all();
|
||||
|
||||
return $this->scopeIdsByUserWorkspace[$cacheKey];
|
||||
}
|
||||
|
||||
private function hydrateTenantBoundary(ManagedEnvironment $tenant): ?ManagedEnvironment
|
||||
{
|
||||
if ($tenant->exists && $tenant->workspace_id !== null) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$tenantKey = $tenant->getKey();
|
||||
|
||||
if (! is_numeric($tenantKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->whereKey((int) $tenantKey)
|
||||
->first(['id', 'workspace_id']);
|
||||
}
|
||||
|
||||
private function scopeCacheKey(User $user, int $workspaceId): string
|
||||
{
|
||||
return implode(':', [(string) $user->getKey(), (string) $workspaceId]);
|
||||
}
|
||||
}
|
||||
@ -17,10 +17,7 @@ public function __construct(public AuditLogger $auditLogger) {}
|
||||
|
||||
public function tenantHasNoOwners(ManagedEnvironment $tenant): bool
|
||||
{
|
||||
return ! ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
return false;
|
||||
}
|
||||
|
||||
public function userHasDuplicateMemberships(ManagedEnvironment $tenant, User $user): bool
|
||||
@ -44,10 +41,7 @@ public function mergeDuplicateMembershipsForUser(ManagedEnvironment $tenant, Use
|
||||
return;
|
||||
}
|
||||
|
||||
$roles = $memberships->pluck('role')->all();
|
||||
$roleToKeep = $this->highestRole($roles);
|
||||
|
||||
$membershipToKeep = $memberships->firstWhere('role', $roleToKeep) ?? $memberships->first();
|
||||
$membershipToKeep = $memberships->first();
|
||||
if (! $membershipToKeep instanceof ManagedEnvironmentMembership) {
|
||||
return;
|
||||
}
|
||||
@ -57,10 +51,6 @@ public function mergeDuplicateMembershipsForUser(ManagedEnvironment $tenant, Use
|
||||
->pluck($membershipToKeep->getKeyName())
|
||||
->all();
|
||||
|
||||
$membershipToKeep->forceFill([
|
||||
'role' => $roleToKeep,
|
||||
])->save();
|
||||
|
||||
ManagedEnvironmentMembership::query()
|
||||
->whereIn($membershipToKeep->getKeyName(), $idsToDelete)
|
||||
->delete();
|
||||
@ -73,8 +63,6 @@ public function mergeDuplicateMembershipsForUser(ManagedEnvironment $tenant, Use
|
||||
'member_user_id' => (int) $member->getKey(),
|
||||
'kept_membership_id' => (string) $membershipToKeep->getKey(),
|
||||
'deleted_membership_ids' => array_values(array_map('strval', $idsToDelete)),
|
||||
'result_role' => $roleToKeep,
|
||||
'source_roles' => $roles,
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
@ -87,28 +75,4 @@ public function mergeDuplicateMembershipsForUser(ManagedEnvironment $tenant, Use
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string|null> $roles
|
||||
*/
|
||||
private function highestRole(array $roles): string
|
||||
{
|
||||
$priority = [
|
||||
'owner' => 3,
|
||||
'manager' => 2,
|
||||
'readonly' => 1,
|
||||
];
|
||||
|
||||
$bestRole = 'readonly';
|
||||
$bestScore = 0;
|
||||
|
||||
foreach ($roles as $role) {
|
||||
$score = $priority[$role] ?? 0;
|
||||
if ($score > $bestScore) {
|
||||
$bestScore = $score;
|
||||
$bestRole = (string) $role;
|
||||
}
|
||||
}
|
||||
|
||||
return $bestRole;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,14 +5,21 @@
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use DomainException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantMembershipManager
|
||||
{
|
||||
public function __construct(public AuditLogger $auditLogger) {}
|
||||
public function __construct(
|
||||
public AuditLogger $auditLogger,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
private readonly ManagedEnvironmentAccessScopeResolver $managedEnvironmentAccessScopeResolver,
|
||||
) {}
|
||||
|
||||
public function addMember(
|
||||
ManagedEnvironment $tenant,
|
||||
@ -22,42 +29,39 @@ public function addMember(
|
||||
string $source = 'manual',
|
||||
?string $sourceRef = null,
|
||||
): ManagedEnvironmentMembership {
|
||||
$this->assertValidRole($role);
|
||||
return $this->grantScope(
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
member: $member,
|
||||
source: $source,
|
||||
sourceRef: $sourceRef,
|
||||
);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): ManagedEnvironmentMembership {
|
||||
public function grantScope(
|
||||
ManagedEnvironment $tenant,
|
||||
User $actor,
|
||||
User $member,
|
||||
string $source = 'manual',
|
||||
?string $sourceRef = null,
|
||||
): ManagedEnvironmentMembership {
|
||||
$workspace = $this->workspaceForTenant($tenant);
|
||||
$memberWorkspaceRole = $this->memberWorkspaceRole($workspace, $member);
|
||||
$this->assertActorCanManageScope($actor, $workspace);
|
||||
|
||||
$membership = DB::transaction(function () use ($tenant, $actor, $member, $memberWorkspaceRole, $source, $sourceRef): ManagedEnvironmentMembership {
|
||||
$existing = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->where('user_id', $member->getKey())
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->role !== $role) {
|
||||
$existing->forceFill([
|
||||
'role' => $role,
|
||||
'source' => $source,
|
||||
'source_ref' => $sourceRef,
|
||||
'created_by_user_id' => (int) $actor->getKey(),
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantMembershipRoleChange->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $member->getKey(),
|
||||
'from_role' => $existing->getOriginal('role'),
|
||||
'to_role' => $role,
|
||||
'source' => $source,
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
}
|
||||
$existing->forceFill([
|
||||
'role' => $memberWorkspaceRole,
|
||||
'source' => $source,
|
||||
'source_ref' => $sourceRef,
|
||||
'created_by_user_id' => (int) $actor->getKey(),
|
||||
])->save();
|
||||
|
||||
return $existing->refresh();
|
||||
}
|
||||
@ -65,7 +69,7 @@ public function addMember(
|
||||
$membership = ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => $role,
|
||||
'role' => $memberWorkspaceRole,
|
||||
'source' => $source,
|
||||
'source_ref' => $sourceRef,
|
||||
'created_by_user_id' => (int) $actor->getKey(),
|
||||
@ -73,11 +77,11 @@ public function addMember(
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantMembershipAdd->value,
|
||||
action: AuditActionId::ManagedEnvironmentAccessScopeGrant->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $member->getKey(),
|
||||
'role' => $role,
|
||||
'workspace_role' => $memberWorkspaceRole,
|
||||
'source' => $source,
|
||||
],
|
||||
],
|
||||
@ -91,134 +95,51 @@ public function addMember(
|
||||
|
||||
return $membership;
|
||||
});
|
||||
|
||||
$this->managedEnvironmentAccessScopeResolver->clearCache();
|
||||
|
||||
return $membership;
|
||||
}
|
||||
|
||||
public function changeRole(ManagedEnvironment $tenant, User $actor, ManagedEnvironmentMembership $membership, string $newRole): ManagedEnvironmentMembership
|
||||
{
|
||||
$this->assertValidRole($newRole);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): ManagedEnvironmentMembership {
|
||||
$membership->refresh();
|
||||
|
||||
if ($membership->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
throw new DomainException('Membership belongs to a different tenant.');
|
||||
}
|
||||
|
||||
$oldRole = $membership->role;
|
||||
|
||||
if ($oldRole === $newRole) {
|
||||
return $membership;
|
||||
}
|
||||
|
||||
$this->guardLastOwnerDemotion($tenant, $membership, $newRole);
|
||||
|
||||
$membership->forceFill([
|
||||
'role' => $newRole,
|
||||
])->save();
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantMembershipRoleChange->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $membership->user_id,
|
||||
'from_role' => $oldRole,
|
||||
'to_role' => $newRole,
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
|
||||
return $membership->refresh();
|
||||
});
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantMembershipLastOwnerBlocked->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $membership->user_id,
|
||||
'from_role' => (string) $membership->role,
|
||||
'attempted_to_role' => $newRole,
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: 'blocked',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
throw new DomainException('Managed-environment access scopes do not manage roles. Change the workspace role instead.');
|
||||
}
|
||||
|
||||
public function removeMember(ManagedEnvironment $tenant, User $actor, ManagedEnvironmentMembership $membership): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($tenant, $actor, $membership): void {
|
||||
$membership->refresh();
|
||||
$workspace = $this->workspaceForTenant($tenant);
|
||||
$this->assertActorCanManageScope($actor, $workspace);
|
||||
|
||||
if ($membership->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
throw new DomainException('Membership belongs to a different tenant.');
|
||||
}
|
||||
DB::transaction(function () use ($tenant, $actor, $membership): void {
|
||||
$membership->refresh();
|
||||
|
||||
$this->guardLastOwnerRemoval($tenant, $membership);
|
||||
|
||||
$memberUserId = (int) $membership->user_id;
|
||||
$oldRole = (string) $membership->role;
|
||||
|
||||
$membership->delete();
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantMembershipRemove->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => $memberUserId,
|
||||
'role' => $oldRole,
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
});
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::TenantMembershipLastOwnerBlocked->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => (int) $membership->user_id,
|
||||
'role' => (string) $membership->role,
|
||||
'attempted_action' => 'remove',
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: 'blocked',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
if ($membership->managed_environment_id !== (int) $tenant->getKey()) {
|
||||
throw new DomainException('Membership belongs to a different tenant.');
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
$memberUserId = (int) $membership->user_id;
|
||||
|
||||
$membership->delete();
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::ManagedEnvironmentAccessScopeRemove->value,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'member_user_id' => $memberUserId,
|
||||
],
|
||||
],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: 'success',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
$this->managedEnvironmentAccessScopeResolver->clearCache();
|
||||
}
|
||||
|
||||
public function bootstrapRecover(ManagedEnvironment $tenant, User $actor, User $member): ManagedEnvironmentMembership
|
||||
@ -250,46 +171,39 @@ public function bootstrapRecover(ManagedEnvironment $tenant, User $actor, User $
|
||||
return $membership;
|
||||
}
|
||||
|
||||
private function guardLastOwnerRemoval(ManagedEnvironment $tenant, ManagedEnvironmentMembership $membership): void
|
||||
private function workspaceForTenant(ManagedEnvironment $tenant): Workspace
|
||||
{
|
||||
if ($membership->role !== 'owner') {
|
||||
return;
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
|
||||
}
|
||||
|
||||
$owners = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('role', 'owner')
|
||||
->count();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new DomainException('Managed environment does not belong to a workspace.');
|
||||
}
|
||||
|
||||
if ($owners <= 1) {
|
||||
throw new DomainException('You cannot remove the last remaining owner.');
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
private function assertActorCanManageScope(User $actor, Workspace $workspace): void
|
||||
{
|
||||
if (! $this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) {
|
||||
throw new DomainException('Forbidden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function guardLastOwnerDemotion(ManagedEnvironment $tenant, ManagedEnvironmentMembership $membership, string $newRole): void
|
||||
private function memberWorkspaceRole(Workspace $workspace, User $member): string
|
||||
{
|
||||
if ($membership->role !== 'owner') {
|
||||
return;
|
||||
$membership = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('user_id', (int) $member->getKey())
|
||||
->first(['role']);
|
||||
|
||||
if (! $membership instanceof WorkspaceMembership) {
|
||||
throw new DomainException('Environment access scope can only be granted to workspace members.');
|
||||
}
|
||||
|
||||
if ($newRole === 'owner') {
|
||||
return;
|
||||
}
|
||||
|
||||
$owners = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('role', 'owner')
|
||||
->count();
|
||||
|
||||
if ($owners <= 1) {
|
||||
throw new DomainException('You cannot demote the last remaining owner.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertValidRole(string $role): void
|
||||
{
|
||||
if (! in_array($role, ['owner', 'manager', 'operator', 'readonly'], true)) {
|
||||
throw new DomainException('Invalid role value.');
|
||||
}
|
||||
return (string) $membership->role;
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,33 @@ public function clearCache(): void
|
||||
$this->resolvedMemberships = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prime workspace membership cache for a set of workspaces in one query.
|
||||
*
|
||||
* @param array<int, int|string> $workspaceIds
|
||||
*/
|
||||
public function primeMemberships(User $user, array $workspaceIds): void
|
||||
{
|
||||
$workspaceIds = array_values(array_unique(array_map(static fn ($id): int => (int) $id, $workspaceIds)));
|
||||
|
||||
if ($workspaceIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$memberships = WorkspaceMembership::query()
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->whereIn('workspace_id', $workspaceIds)
|
||||
->get(['workspace_id', 'role']);
|
||||
|
||||
$byWorkspaceId = $memberships->keyBy('workspace_id');
|
||||
|
||||
foreach ($workspaceIds as $workspaceId) {
|
||||
$cacheKey = "workspace_membership_{$user->id}_{$workspaceId}";
|
||||
$membership = $byWorkspaceId->get($workspaceId);
|
||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
private function logDenial(User $user, Workspace $workspace, string $capability): void
|
||||
{
|
||||
$key = implode(':', [(string) $user->getKey(), (string) $workspace->getKey(), $capability]);
|
||||
@ -86,7 +113,7 @@ private function getMembership(User $user, Workspace $workspace): ?array
|
||||
{
|
||||
$cacheKey = "workspace_membership_{$user->id}_{$workspace->id}";
|
||||
|
||||
if (! isset($this->resolvedMemberships[$cacheKey])) {
|
||||
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
|
||||
$membership = WorkspaceMembership::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('workspace_id', $workspace->id)
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -16,7 +18,11 @@
|
||||
|
||||
class WorkspaceMembershipManager
|
||||
{
|
||||
public function __construct(public WorkspaceAuditLogger $auditLogger) {}
|
||||
public function __construct(
|
||||
public WorkspaceAuditLogger $auditLogger,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
private readonly ManagedEnvironmentAccessScopeResolver $managedEnvironmentAccessScopeResolver,
|
||||
) {}
|
||||
|
||||
public function addMember(
|
||||
Workspace $workspace,
|
||||
@ -29,7 +35,7 @@ public function addMember(
|
||||
$this->assertActorCanManage($actor, $workspace);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
|
||||
$membership = DB::transaction(function () use ($workspace, $actor, $member, $role, $source): WorkspaceMembership {
|
||||
$existing = WorkspaceMembership::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('user_id', (int) $member->getKey())
|
||||
@ -90,6 +96,10 @@ public function addMember(
|
||||
|
||||
return $membership;
|
||||
});
|
||||
|
||||
$this->clearAuthorizationCaches();
|
||||
|
||||
return $membership;
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||
$this->auditLastOwnerBlocked(
|
||||
@ -112,7 +122,7 @@ public function changeRole(Workspace $workspace, User $actor, WorkspaceMembershi
|
||||
$this->assertActorCanManage($actor, $workspace);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership {
|
||||
$membership = DB::transaction(function () use ($workspace, $actor, $membership, $newRole): WorkspaceMembership {
|
||||
$membership->refresh();
|
||||
|
||||
if ($membership->workspace_id !== (int) $workspace->getKey()) {
|
||||
@ -149,6 +159,10 @@ public function changeRole(Workspace $workspace, User $actor, WorkspaceMembershi
|
||||
|
||||
return $membership->refresh();
|
||||
});
|
||||
|
||||
$this->clearAuthorizationCaches();
|
||||
|
||||
return $membership;
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||
$this->auditLastOwnerBlocked(
|
||||
@ -182,6 +196,16 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers
|
||||
$memberUserId = (int) $membership->user_id;
|
||||
$oldRole = (string) $membership->role;
|
||||
|
||||
ManagedEnvironmentMembership::query()
|
||||
->where('user_id', $memberUserId)
|
||||
->whereIn(
|
||||
'managed_environment_id',
|
||||
ManagedEnvironment::query()
|
||||
->select('id')
|
||||
->where('workspace_id', (int) $workspace->getKey()),
|
||||
)
|
||||
->delete();
|
||||
|
||||
$membership->delete();
|
||||
|
||||
$this->auditLogger->log(
|
||||
@ -199,6 +223,8 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
$this->clearAuthorizationCaches();
|
||||
} catch (DomainException $exception) {
|
||||
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
||||
$this->auditLastOwnerBlocked(
|
||||
@ -217,14 +243,17 @@ public function removeMember(Workspace $workspace, User $actor, WorkspaceMembers
|
||||
|
||||
private function assertActorCanManage(User $actor, Workspace $workspace): void
|
||||
{
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) {
|
||||
if (! $this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)) {
|
||||
throw new DomainException('Forbidden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function clearAuthorizationCaches(): void
|
||||
{
|
||||
$this->workspaceCapabilityResolver->clearCache();
|
||||
$this->managedEnvironmentAccessScopeResolver->clearCache();
|
||||
}
|
||||
|
||||
private function assertValidRole(string $role): void
|
||||
{
|
||||
$valid = array_map(
|
||||
|
||||
@ -97,7 +97,16 @@ public static function getCapabilities(WorkspaceRole|string $role): array
|
||||
{
|
||||
$roleValue = $role instanceof WorkspaceRole ? $role->value : $role;
|
||||
|
||||
return self::$roleCapabilities[$roleValue] ?? [];
|
||||
$capabilities = array_merge(
|
||||
self::$roleCapabilities[$roleValue] ?? [],
|
||||
RoleCapabilityMap::getCapabilities($roleValue),
|
||||
);
|
||||
|
||||
if ($roleValue === WorkspaceRole::Manager->value) {
|
||||
$capabilities[] = Capabilities::TENANT_MEMBERSHIP_MANAGE;
|
||||
}
|
||||
|
||||
return array_values(array_unique($capabilities));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -107,8 +116,8 @@ public static function rolesWithCapability(string $capability): array
|
||||
{
|
||||
$roles = [];
|
||||
|
||||
foreach (self::$roleCapabilities as $role => $capabilities) {
|
||||
if (in_array($capability, $capabilities, true)) {
|
||||
foreach (array_keys(self::$roleCapabilities) as $role) {
|
||||
if (in_array($capability, self::getCapabilities($role), true)) {
|
||||
$roles[] = $role;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Finding;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
@ -36,7 +37,7 @@ public function __construct(
|
||||
*/
|
||||
public function visibleTenants(Workspace $workspace, User $user): array
|
||||
{
|
||||
$authorizedTenants = $user->tenants()
|
||||
$authorizedTenants = ManagedEnvironment::query()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
->where('managed_environments.lifecycle_status', 'active')
|
||||
->orderBy('managed_environments.name')
|
||||
@ -122,11 +123,25 @@ private function issueQueryForVisibleTenantIds(
|
||||
->withSubjectDisplayName()
|
||||
->join('managed_environments', 'managed_environments.id', '=', 'findings.managed_environment_id')
|
||||
->leftJoin('users as hygiene_assignee_lookup', 'hygiene_assignee_lookup.id', '=', 'findings.assignee_user_id')
|
||||
->leftJoin('managed_environment_memberships as hygiene_assignee_membership', function ($join): void {
|
||||
->leftJoin('workspace_memberships as hygiene_assignee_workspace_membership', function ($join): void {
|
||||
$join
|
||||
->on('hygiene_assignee_membership.managed_environment_id', '=', 'findings.managed_environment_id')
|
||||
->on('hygiene_assignee_membership.user_id', '=', 'findings.assignee_user_id');
|
||||
->on('hygiene_assignee_workspace_membership.workspace_id', '=', 'findings.workspace_id')
|
||||
->on('hygiene_assignee_workspace_membership.user_id', '=', 'findings.assignee_user_id');
|
||||
})
|
||||
->leftJoin('managed_environment_memberships as hygiene_assignee_scope', function ($join): void {
|
||||
$join
|
||||
->on('hygiene_assignee_scope.managed_environment_id', '=', 'findings.managed_environment_id')
|
||||
->on('hygiene_assignee_scope.user_id', '=', 'findings.assignee_user_id');
|
||||
})
|
||||
->leftJoinSub(
|
||||
ManagedEnvironmentMembership::query()
|
||||
->select('managed_environment_id')
|
||||
->groupBy('managed_environment_id'),
|
||||
'hygiene_environment_scope_state',
|
||||
function ($join): void {
|
||||
$join->on('hygiene_environment_scope_state.managed_environment_id', '=', 'findings.managed_environment_id');
|
||||
},
|
||||
)
|
||||
->leftJoinSub(
|
||||
$this->latestMeaningfulWorkflowAuditSubquery(),
|
||||
'hygiene_workflow_audit',
|
||||
@ -294,7 +309,7 @@ private function latestMeaningfulWorkflowAuditSubquery(): Builder
|
||||
|
||||
private function brokenAssignmentExpression(): string
|
||||
{
|
||||
return '(findings.assignee_user_id is not null and ((hygiene_assignee_lookup.id is not null and hygiene_assignee_lookup.deleted_at is not null) or hygiene_assignee_membership.id is null))';
|
||||
return '(findings.assignee_user_id is not null and ((hygiene_assignee_lookup.id is not null and hygiene_assignee_lookup.deleted_at is not null) or hygiene_assignee_workspace_membership.id is null or (hygiene_environment_scope_state.managed_environment_id is not null and hygiene_assignee_scope.id is null)))';
|
||||
}
|
||||
|
||||
private function staleInProgressExpression(string $lastWorkflowActivityExpression): string
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
@ -26,6 +26,7 @@ final class FindingExceptionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly ManagedEnvironmentAccessScopeResolver $managedEnvironmentAccessScopeResolver,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
private readonly FindingWorkflowService $findingWorkflowService,
|
||||
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
||||
@ -667,13 +668,10 @@ private function validatedTenantMemberId(ManagedEnvironment $tenant, mixed $user
|
||||
|
||||
$resolvedUserId = (int) $userId;
|
||||
|
||||
$isMember = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('user_id', $resolvedUserId)
|
||||
->exists();
|
||||
$member = User::query()->whereKey($resolvedUserId)->first();
|
||||
|
||||
if (! $isMember) {
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $label));
|
||||
if (! $member instanceof User || ! $this->managedEnvironmentAccessScopeResolver->canAccess($member, $tenant)) {
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a workspace member with access to this environment.', $label));
|
||||
}
|
||||
|
||||
return $resolvedUserId;
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
@ -28,6 +28,7 @@ public function __construct(
|
||||
private readonly FindingSlaPolicy $slaPolicy,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly ManagedEnvironmentAccessScopeResolver $managedEnvironmentAccessScopeResolver,
|
||||
private readonly FindingNotificationService $findingNotificationService,
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
@ -563,13 +564,10 @@ private function assertTenantMemberOrNull(ManagedEnvironment $tenant, ?int $user
|
||||
throw new InvalidArgumentException(sprintf('%s must be a positive user id.', $field));
|
||||
}
|
||||
|
||||
$isMember = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('user_id', $userId)
|
||||
->exists();
|
||||
$member = User::query()->whereKey($userId)->first();
|
||||
|
||||
if (! $isMember) {
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field));
|
||||
if (! $member instanceof User || ! $this->managedEnvironmentAccessScopeResolver->canAccess($member, $tenant)) {
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a workspace member with access to this environment.', $field));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,25 +9,42 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class TenantReviewRegisterService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly ManagedEnvironmentAccessScopeResolver $managedEnvironmentAccessScopeResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, ManagedEnvironment>
|
||||
*/
|
||||
public function authorizedTenants(User $user, Workspace $workspace): array
|
||||
{
|
||||
$roles = RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_REVIEW_VIEW);
|
||||
$query = ManagedEnvironment::query()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey());
|
||||
|
||||
return $user->tenants()
|
||||
->where('managed_environments.workspace_id', (int) $workspace->getKey())
|
||||
->wherePivotIn('role', $roles)
|
||||
$this->managedEnvironmentAccessScopeResolver->applyWorkspaceScopeToQuery(
|
||||
query: $query,
|
||||
user: $user,
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
qualifiedEnvironmentColumn: 'managed_environments.id',
|
||||
);
|
||||
|
||||
$tenants = $query
|
||||
->orderBy('managed_environments.name')
|
||||
->get()
|
||||
->get();
|
||||
|
||||
$this->capabilityResolver->primeMemberships($user, $tenants->modelKeys());
|
||||
|
||||
return $tenants
|
||||
->filter(fn (ManagedEnvironment $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW))
|
||||
->keyBy(static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
}
|
||||
|
||||
@ -22,6 +22,8 @@ enum AuditActionId: string
|
||||
case TenantMembershipRoleChange = 'tenant_membership.role_change';
|
||||
case TenantMembershipRemove = 'tenant_membership.remove';
|
||||
case TenantMembershipLastOwnerBlocked = 'tenant_membership.last_owner_blocked';
|
||||
case ManagedEnvironmentAccessScopeGrant = 'managed_environment_access_scope.grant';
|
||||
case ManagedEnvironmentAccessScopeRemove = 'managed_environment_access_scope.remove';
|
||||
|
||||
// Not part of the v1 contract, but used in codebase.
|
||||
case TenantMembershipBootstrapRecover = 'tenant_membership.bootstrap_recover';
|
||||
@ -203,6 +205,8 @@ private static function labels(): array
|
||||
self::TenantMembershipRoleChange->value => 'ManagedEnvironment member role change',
|
||||
self::TenantMembershipRemove->value => 'ManagedEnvironment member removal',
|
||||
self::TenantMembershipLastOwnerBlocked->value => 'ManagedEnvironment last-owner protection',
|
||||
self::ManagedEnvironmentAccessScopeGrant->value => 'ManagedEnvironment access scope grant',
|
||||
self::ManagedEnvironmentAccessScopeRemove->value => 'ManagedEnvironment access scope removal',
|
||||
self::PolicyProviderMissingDetected->value => 'Policy provider missing detected',
|
||||
self::PolicyProviderMissingCleared->value => 'Policy provider missing cleared',
|
||||
self::ManagedTenantOnboardingStart->value => 'Managed tenant onboarding start',
|
||||
|
||||
@ -98,7 +98,7 @@ public static function forBulkAction(BulkAction $action): self
|
||||
}
|
||||
|
||||
/**
|
||||
* Require tenant membership for this action.
|
||||
* Require managed-environment access for this action.
|
||||
*
|
||||
* @param bool $require Whether membership is required (default: true)
|
||||
*/
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Findings\FindingAssignmentHygieneService;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
@ -51,6 +52,7 @@ final class WorkspaceOverviewBuilder
|
||||
public function __construct(
|
||||
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
private ManagedEnvironmentAccessScopeResolver $managedEnvironmentAccessScopeResolver,
|
||||
private FindingAssignmentHygieneService $findingAssignmentHygieneService,
|
||||
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
||||
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
||||
@ -202,10 +204,18 @@ public function build(Workspace $workspace, User $user): array
|
||||
*/
|
||||
private function accessibleTenants(Workspace $workspace, User $user): Collection
|
||||
{
|
||||
return ManagedEnvironment::query()
|
||||
$query = ManagedEnvironment::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('lifecycle_status', ManagedEnvironment::STATUS_ACTIVE)
|
||||
->whereIn('id', $user->tenantMemberships()->select('managed_environment_id'))
|
||||
->where('lifecycle_status', ManagedEnvironment::STATUS_ACTIVE);
|
||||
|
||||
$this->managedEnvironmentAccessScopeResolver->applyWorkspaceScopeToQuery(
|
||||
query: $query,
|
||||
user: $user,
|
||||
workspaceId: (int) $workspace->getKey(),
|
||||
qualifiedEnvironmentColumn: 'managed_environments.id',
|
||||
);
|
||||
|
||||
return $query
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'slug', 'workspace_id']);
|
||||
}
|
||||
@ -984,7 +994,7 @@ private function summaryMetrics(
|
||||
category: 'scope',
|
||||
description: $accessibleTenantCount > 0
|
||||
? 'ManagedEnvironment drill-down stays explicit from this workspace home.'
|
||||
: 'No tenant memberships are available in this workspace yet.',
|
||||
: 'No managed environments are available in this workspace yet.',
|
||||
color: $accessibleTenantCount > 0 ? 'primary' : 'warning',
|
||||
destination: $accessibleTenantCount > 0
|
||||
? $this->chooseTenantTarget()
|
||||
|
||||
@ -36,8 +36,7 @@ public function resolve(Workspace $workspace, User $user, ?string $intendedUrl =
|
||||
return $intendedUrl;
|
||||
}
|
||||
|
||||
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants()
|
||||
->where('workspace_id', $workspace->getKey())
|
||||
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->accessibleManagedEnvironmentsQuery((int) $workspace->getKey())
|
||||
->orderBy('name')
|
||||
->get());
|
||||
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Services\Auth\WorkspaceMembershipManager;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
pest()->browser()->timeout(20_000);
|
||||
|
||||
it('smokes workspace role management plus scoped environment drilldown', function (): void {
|
||||
$workspace = Workspace::factory()->create([
|
||||
'name' => 'Spec 285 Workspace',
|
||||
]);
|
||||
$allowedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Spec 285 Allowed',
|
||||
'slug' => 'spec-285-allowed',
|
||||
]);
|
||||
$deniedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Spec 285 Denied',
|
||||
'slug' => 'spec-285-denied',
|
||||
]);
|
||||
$owner = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
$memberMembership = WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
app(WorkspaceMembershipManager::class)->changeRole($workspace, $owner, $memberMembership, 'operator');
|
||||
app(TenantMembershipManager::class)->grantScope($allowedTenant, $owner, $member, source: 'manual');
|
||||
|
||||
Finding::factory()->for($allowedTenant)->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
]);
|
||||
|
||||
$this->actingAs($member)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
visit(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
|
||||
->waitForText('Spec 285 Allowed')
|
||||
->assertSee('Spec 285 Allowed')
|
||||
->assertDontSee('Spec 285 Denied')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(FindingResource::getUrl('index', tenant: $allowedTenant, panel: 'admin'))
|
||||
->waitForText('Findings')
|
||||
->assertScript("window.location.pathname.includes('/admin/workspaces/{$workspace->getRouteKey()}/environments/{$allowedTenant->getRouteKey()}/findings')", true)
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
@ -3,32 +3,30 @@
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
|
||||
it('writes canonical audit action IDs for membership mutations', function () {
|
||||
it('writes canonical audit action IDs for environment access-scope mutations', function () {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$member = User::factory()->create();
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
/** @var TenantMembershipManager $manager */
|
||||
$manager = app(TenantMembershipManager::class);
|
||||
|
||||
$membership = $manager->addMember(
|
||||
$membership = $manager->grantScope(
|
||||
tenant: $tenant,
|
||||
actor: $owner,
|
||||
member: $member,
|
||||
role: 'readonly',
|
||||
source: 'manual',
|
||||
);
|
||||
|
||||
$manager->changeRole(
|
||||
tenant: $tenant,
|
||||
actor: $owner,
|
||||
membership: $membership,
|
||||
newRole: 'operator',
|
||||
);
|
||||
|
||||
$manager->removeMember(
|
||||
tenant: $tenant,
|
||||
actor: $owner,
|
||||
@ -38,49 +36,37 @@
|
||||
$logs = AuditLog::query()
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->whereIn('action', [
|
||||
AuditActionId::TenantMembershipAdd->value,
|
||||
AuditActionId::TenantMembershipRoleChange->value,
|
||||
AuditActionId::TenantMembershipRemove->value,
|
||||
AuditActionId::ManagedEnvironmentAccessScopeGrant->value,
|
||||
AuditActionId::ManagedEnvironmentAccessScopeRemove->value,
|
||||
])
|
||||
->get()
|
||||
->keyBy('action');
|
||||
|
||||
expect($logs)->toHaveCount(3);
|
||||
expect($logs)->toHaveCount(2);
|
||||
|
||||
$addLog = $logs->get(AuditActionId::TenantMembershipAdd->value);
|
||||
$roleChangeLog = $logs->get(AuditActionId::TenantMembershipRoleChange->value);
|
||||
$removeLog = $logs->get(AuditActionId::TenantMembershipRemove->value);
|
||||
$grantLog = $logs->get(AuditActionId::ManagedEnvironmentAccessScopeGrant->value);
|
||||
$removeLog = $logs->get(AuditActionId::ManagedEnvironmentAccessScopeRemove->value);
|
||||
|
||||
expect($addLog)->not->toBeNull();
|
||||
expect($roleChangeLog)->not->toBeNull();
|
||||
expect($grantLog)->not->toBeNull();
|
||||
expect($removeLog)->not->toBeNull();
|
||||
|
||||
expect($addLog->status)->toBe('success');
|
||||
expect($roleChangeLog->status)->toBe('success');
|
||||
expect($grantLog->status)->toBe('success');
|
||||
expect($removeLog->status)->toBe('success');
|
||||
|
||||
expect($addLog->metadata)
|
||||
expect($grantLog->metadata)
|
||||
->toHaveKey('member_user_id', $member->id)
|
||||
->toHaveKey('role', 'readonly')
|
||||
->toHaveKey('workspace_role', 'readonly')
|
||||
->toHaveKey('source', 'manual')
|
||||
->not->toHaveKey('member_email')
|
||||
->not->toHaveKey('member_name');
|
||||
|
||||
expect($roleChangeLog->metadata)
|
||||
->toHaveKey('member_user_id', $member->id)
|
||||
->toHaveKey('from_role', 'readonly')
|
||||
->toHaveKey('to_role', 'operator')
|
||||
->not->toHaveKey('member_email')
|
||||
->not->toHaveKey('member_name');
|
||||
|
||||
expect($removeLog->metadata)
|
||||
->toHaveKey('member_user_id', $member->id)
|
||||
->toHaveKey('role', 'operator')
|
||||
->not->toHaveKey('member_email')
|
||||
->not->toHaveKey('member_name');
|
||||
});
|
||||
|
||||
it('writes a last-owner-blocked audit log when demoting or removing the last owner', function () {
|
||||
it('rejects managed-environment role-change attempts without writing role-change audit truth', function () {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$membership = ManagedEnvironmentMembership::query()
|
||||
@ -98,33 +84,8 @@
|
||||
newRole: 'manager',
|
||||
))->toThrow(DomainException::class);
|
||||
|
||||
expect(fn () => $manager->removeMember(
|
||||
tenant: $tenant,
|
||||
actor: $owner,
|
||||
membership: $membership,
|
||||
))->toThrow(DomainException::class);
|
||||
|
||||
$blockedLogs = AuditLog::query()
|
||||
expect(AuditLog::query()
|
||||
->where('managed_environment_id', $tenant->id)
|
||||
->where('action', AuditActionId::TenantMembershipLastOwnerBlocked->value)
|
||||
->where('status', 'blocked')
|
||||
->get();
|
||||
|
||||
expect($blockedLogs->count())->toBeGreaterThanOrEqual(2);
|
||||
|
||||
expect($blockedLogs->contains(fn (AuditLog $log): bool => (
|
||||
($log->metadata['member_user_id'] ?? null) === $owner->id
|
||||
&& ($log->metadata['attempted_to_role'] ?? null) === 'manager'
|
||||
)))->toBeTrue();
|
||||
|
||||
expect($blockedLogs->contains(fn (AuditLog $log): bool => (
|
||||
($log->metadata['member_user_id'] ?? null) === $owner->id
|
||||
&& ($log->metadata['attempted_action'] ?? null) === 'remove'
|
||||
)))->toBeTrue();
|
||||
|
||||
foreach ($blockedLogs as $log) {
|
||||
expect($log->metadata)
|
||||
->not->toHaveKey('member_email')
|
||||
->not->toHaveKey('member_name');
|
||||
}
|
||||
->where('action', AuditActionId::TenantMembershipRoleChange->value)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\PanelRegistry;
|
||||
|
||||
it('allows workspace members to access active environments without environment scope rows', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenantA = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Tenant A',
|
||||
]);
|
||||
$tenantB = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Tenant B',
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
/** @var \Filament\Panel $panel */
|
||||
$panel = app(PanelRegistry::class)->get('admin');
|
||||
$tenants = $user->getTenants($panel);
|
||||
|
||||
expect($user->canAccessTenant($tenantA))->toBeTrue()
|
||||
->and($user->canAccessTenant($tenantB))->toBeTrue()
|
||||
->and($tenants->pluck('name')->all())->toEqual(['Tenant A', 'Tenant B']);
|
||||
});
|
||||
|
||||
it('narrows tenant selection and clears stale remembered environment context', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$allowedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Allowed Tenant',
|
||||
]);
|
||||
$deniedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Denied Tenant',
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $allowedTenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
app(CapabilityResolver::class)->clearCache();
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
||||
|
||||
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspace->getKey(), (int) $deniedTenant->getKey());
|
||||
|
||||
/** @var \Filament\Panel $panel */
|
||||
$panel = app(PanelRegistry::class)->get('admin');
|
||||
$defaultTenant = $user->getDefaultTenant($panel);
|
||||
$tenants = $user->getTenants($panel);
|
||||
|
||||
expect($defaultTenant?->getKey())->toBe($allowedTenant->getKey())
|
||||
->and(app(WorkspaceContext::class)->lastTenantId())->toBeNull()
|
||||
->and($tenants)->toHaveCount(1)
|
||||
->and($tenants->first()?->getKey())->toBe($allowedTenant->getKey());
|
||||
});
|
||||
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Filament\Actions\Action;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('manages explicit environment access scopes without exposing role authority', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$owner = User::factory()->create();
|
||||
$member = User::factory()->create(['name' => 'Scoped Member']);
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(TenantMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $tenant,
|
||||
'pageClass' => ManageTenantMemberships::class,
|
||||
])
|
||||
->callTableAction('add_member', null, [
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$scope = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('user_id', (int) $member->getKey())
|
||||
->first();
|
||||
|
||||
expect($scope)->not->toBeNull()
|
||||
->and($scope?->role)->toBe('readonly')
|
||||
->and(AuditLog::query()->where('action', AuditActionId::ManagedEnvironmentAccessScopeGrant->value)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps explicit scope removal destructive and audit logged', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$owner = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
$scope = ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'operator',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(TenantMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $tenant,
|
||||
'pageClass' => ManageTenantMemberships::class,
|
||||
])
|
||||
->assertTableActionExists('remove', fn (Action $action): bool => $action->isConfirmationRequired(), $scope)
|
||||
->callTableAction('remove', $scope);
|
||||
|
||||
expect(ManagedEnvironmentMembership::query()->whereKey($scope->getKey())->exists())->toBeFalse()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::ManagedEnvironmentAccessScopeRemove->value)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects direct role changes on managed-environment access scopes', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$owner = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
$scope = ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'operator',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
expect(fn () => app(TenantMembershipManager::class)->changeRole($tenant, $owner, $scope, 'owner'))
|
||||
->toThrow(DomainException::class, 'Managed-environment access scopes do not manage roles. Change the workspace role instead.');
|
||||
});
|
||||
@ -2,27 +2,32 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('allows an owner to add, change role, and remove members', function (): void {
|
||||
it('allows an owner to add and remove explicit environment access scopes', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$member = User::factory()->create(['name' => 'Member User']);
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(TenantMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $tenant,
|
||||
'pageClass' => ViewTenant::class,
|
||||
'pageClass' => ManageTenantMemberships::class,
|
||||
])
|
||||
->callTableAction('add_member', null, [
|
||||
'user_id' => $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$membership = ManagedEnvironmentMembership::query()
|
||||
@ -36,79 +41,49 @@
|
||||
Livewire::actingAs($owner)
|
||||
->test(TenantMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $tenant,
|
||||
'pageClass' => ViewTenant::class,
|
||||
])
|
||||
->callTableAction('change_role', $membership, [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
expect($membership?->refresh()->role)->toBe('manager');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(TenantMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $tenant,
|
||||
'pageClass' => ViewTenant::class,
|
||||
'pageClass' => ManageTenantMemberships::class,
|
||||
])
|
||||
->callTableAction('remove', $membership);
|
||||
|
||||
expect(ManagedEnvironmentMembership::query()->whereKey($membership?->getKey())->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('hides membership management actions from non-owners', function (): void {
|
||||
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
it('hides scope management actions from readonly workspace members', function (): void {
|
||||
[$readonly, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$member = User::factory()->create();
|
||||
$member->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'readonly'],
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$membership = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->where('user_id', $member->getKey())
|
||||
->firstOrFail();
|
||||
$membership = ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($manager)
|
||||
Livewire::actingAs($readonly)
|
||||
->test(TenantMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $tenant,
|
||||
'pageClass' => ViewTenant::class,
|
||||
'pageClass' => ManageTenantMemberships::class,
|
||||
])
|
||||
->assertTableActionVisible('add_member')
|
||||
->assertTableActionDisabled('add_member')
|
||||
->assertTableActionVisible('change_role', $membership)
|
||||
->assertTableActionDisabled('change_role', $membership)
|
||||
->assertTableActionVisible('remove', $membership)
|
||||
->assertTableActionDisabled('remove', $membership);
|
||||
});
|
||||
|
||||
it('prevents removing or demoting the last owner', function (): void {
|
||||
it('rejects role changes on explicit environment access scopes', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$ownerMembership = ManagedEnvironmentMembership::query()
|
||||
$membership = ManagedEnvironmentMembership::query()
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->where('user_id', $owner->getKey())
|
||||
->firstOrFail();
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(TenantMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $tenant,
|
||||
'pageClass' => ViewTenant::class,
|
||||
])
|
||||
->callTableAction('change_role', $ownerMembership, [
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
expect($ownerMembership->refresh()->role)->toBe('owner');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(TenantMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $tenant,
|
||||
'pageClass' => ViewTenant::class,
|
||||
])
|
||||
->callTableAction('remove', $ownerMembership);
|
||||
|
||||
expect(ManagedEnvironmentMembership::query()->whereKey($ownerMembership->getKey())->exists())->toBeTrue();
|
||||
expect(fn () => app(TenantMembershipManager::class)->changeRole($tenant, $owner, $membership, 'manager'))
|
||||
->toThrow(DomainException::class, 'Managed-environment access scopes do not manage roles. Change the workspace role instead.');
|
||||
});
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
|
||||
use App\Filament\Resources\Workspaces\RelationManagers\WorkspaceMembershipsRelationManager;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Actions\Action;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('keeps workspace membership as the canonical role editor', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$actor = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $actor->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
$memberMembership = WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
$scope = ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
app(WorkspaceMembershipManager::class)->changeRole($workspace, $actor, $memberMembership, 'manager');
|
||||
app(CapabilityResolver::class)->clearCache();
|
||||
|
||||
expect(app(CapabilityResolver::class)->getRole($member, $tenant)?->value)->toBe('manager')
|
||||
->and(app(CapabilityResolver::class)->can($member, $tenant, Capabilities::TENANT_MANAGE))->toBeTrue()
|
||||
->and($scope->refresh()->role)->toBe('readonly');
|
||||
});
|
||||
|
||||
it('keeps last-owner protection anchored at workspace scope', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
|
||||
$ownerMembership = WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
expect(fn () => app(WorkspaceMembershipManager::class)->changeRole($workspace, $owner, $ownerMembership, 'manager'))
|
||||
->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
|
||||
|
||||
expect($ownerMembership->refresh()->role)->toBe('owner');
|
||||
});
|
||||
|
||||
it('keeps workspace role mutations confirmation protected in the relation manager', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
$memberMembership = WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(WorkspaceMembershipsRelationManager::class, [
|
||||
'ownerRecord' => $workspace,
|
||||
'pageClass' => ViewWorkspace::class,
|
||||
])
|
||||
->assertTableActionExists('change_role', fn (Action $action): bool => $action->isConfirmationRequired(), $memberMembership)
|
||||
->assertTableActionExists('remove', fn (Action $action): bool => $action->isConfirmationRequired(), $memberMembership);
|
||||
});
|
||||
@ -22,18 +22,17 @@
|
||||
$mock->shouldReceive('request')->never();
|
||||
});
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
$connection = ProviderConnection::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->firstOrFail();
|
||||
|
||||
Livewire::test(ListProviderConnections::class)
|
||||
->callTableAction('check_connection', $connection);
|
||||
@ -73,18 +72,17 @@
|
||||
it('dedupes connection checks and does not enqueue a second job', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
$connection = ProviderConnection::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->firstOrFail();
|
||||
|
||||
$component = Livewire::test(ListProviderConnections::class);
|
||||
|
||||
@ -102,18 +100,17 @@
|
||||
it('disables connection check action for readonly users', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', fixtureProfile: 'credential-enabled');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->create([
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
$connection = ProviderConnection::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->firstOrFail();
|
||||
|
||||
Livewire::test(ListProviderConnections::class)
|
||||
->assertTableActionVisible('check_connection', $connection)
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
it('authorizes governance artifacts from workspace role without environment membership rows', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$snapshot = spec285EvidenceSnapshot($tenant);
|
||||
$reviewPack = ReviewPack::factory()->ready()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
$tenantReview = TenantReview::factory()->ready()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
expect(Gate::forUser($user)->allows('view', $finding))->toBeTrue()
|
||||
->and(Gate::forUser($user)->allows('view', $snapshot))->toBeTrue()
|
||||
->and(Gate::forUser($user)->allows('view', $reviewPack))->toBeTrue()
|
||||
->and(Gate::forUser($user)->allows('view', $tenantReview))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies out-of-scope governance artifacts as not found before capability checks', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$allowedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$deniedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $allowedTenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
||||
|
||||
$deniedTenant->makeCurrent();
|
||||
|
||||
$finding = Finding::factory()->for($deniedTenant)->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$snapshot = spec285EvidenceSnapshot($deniedTenant);
|
||||
$reviewPack = ReviewPack::factory()->ready()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => (int) $deniedTenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
$tenantReview = TenantReview::factory()->ready()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => (int) $deniedTenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
expect(Gate::forUser($user)->inspect('view', $finding)->status())->toBe(404)
|
||||
->and(Gate::forUser($user)->inspect('view', $snapshot)->status())->toBe(404)
|
||||
->and(Gate::forUser($user)->inspect('view', $reviewPack)->status())->toBe(404)
|
||||
->and(Gate::forUser($user)->inspect('view', $tenantReview)->status())->toBe(404);
|
||||
});
|
||||
|
||||
function spec285EvidenceSnapshot(ManagedEnvironment $tenant): EvidenceSnapshot
|
||||
{
|
||||
return EvidenceSnapshot::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => [],
|
||||
'fingerprint' => hash('sha256', 'spec-285-'.$tenant->getKey().'-'.microtime()),
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
it('authorizes workspace-bound operation runs from workspace membership', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->tenantlessForWorkspace($workspace)->create([
|
||||
'type' => 'tenant.review.compose',
|
||||
]);
|
||||
|
||||
expect(Gate::forUser($user)->allows('view', $run))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies environment-bound operation runs outside explicit scope as not found', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$allowedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$deniedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $allowedTenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
||||
|
||||
$run = OperationRun::factory()->forTenant($deniedTenant)->create([
|
||||
'type' => 'provider.connection.check',
|
||||
]);
|
||||
|
||||
$response = Gate::forUser($user)->inspect('view', $run);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('keeps in-scope operation capability denials distinct from scope boundaries', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'type' => 'inventory.sync',
|
||||
]);
|
||||
|
||||
$response = Gate::forUser($user)->inspect('view', $run);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->not->toBe(404);
|
||||
});
|
||||
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
it('authorizes provider connections from workspace role without environment membership rows', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
expect(Gate::forUser($user)->allows('view', $connection))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies out-of-scope provider connections as not found before capability checks', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$allowedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$deniedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $allowedTenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => (int) $deniedTenant->getKey(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$response = Gate::forUser($user)->inspect('view', $connection);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->toBe(404);
|
||||
});
|
||||
|
||||
it('keeps in-scope capability denials distinct from not-found boundaries', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$response = Gate::forUser($user)->inspect('update', $connection);
|
||||
|
||||
expect($response->denied())->toBeTrue()
|
||||
->and($response->status())->not->toBe(404);
|
||||
});
|
||||
@ -1,14 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('can add, change role, and remove tenant members', function () {
|
||||
it('can add and remove explicit environment access scopes without changing roles', function () {
|
||||
[$actor, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$member = User::factory()->create();
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$manager = app(TenantMembershipManager::class);
|
||||
|
||||
@ -22,11 +28,10 @@
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$updated = $manager->changeRole($tenant, $actor, $membership, 'operator');
|
||||
expect(fn () => $manager->changeRole($tenant, $actor, $membership, 'operator'))
|
||||
->toThrow(DomainException::class);
|
||||
|
||||
expect($updated->role)->toBe('operator');
|
||||
|
||||
$manager->removeMember($tenant, $actor, $updated);
|
||||
$manager->removeMember($tenant, $actor, $membership);
|
||||
|
||||
$this->assertDatabaseMissing('managed_environment_memberships', [
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\PanelRegistry;
|
||||
@ -27,11 +28,12 @@
|
||||
'name' => 'Archived',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$allowed->getKey() => ['role' => 'readonly'],
|
||||
$onboarding->getKey() => ['role' => 'readonly'],
|
||||
$archived->getKey() => ['role' => 'readonly'],
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $allowed->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $allowed->workspace_id);
|
||||
|
||||
/** @var \Filament\Panel $panel */
|
||||
$panel = app(PanelRegistry::class)->get('admin');
|
||||
@ -53,11 +55,6 @@
|
||||
'name' => 'Archived',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$draft->getKey() => ['role' => 'readonly'],
|
||||
$archived->getKey() => ['role' => 'readonly'],
|
||||
]);
|
||||
|
||||
/** @var \Filament\Panel $panel */
|
||||
$panel = app(PanelRegistry::class)->get('admin');
|
||||
|
||||
@ -77,10 +74,10 @@
|
||||
'name' => 'Archived',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$active->getKey() => ['role' => 'readonly'],
|
||||
$onboarding->getKey() => ['role' => 'readonly'],
|
||||
$archived->getKey() => ['role' => 'readonly'],
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $active->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $active->workspace_id);
|
||||
@ -109,10 +106,10 @@
|
||||
'name' => 'Search Archived',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$draft->getKey() => ['role' => 'readonly'],
|
||||
$onboarding->getKey() => ['role' => 'readonly'],
|
||||
$archived->getKey() => ['role' => 'readonly'],
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $draft->workspace_id,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -152,7 +149,7 @@
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
|
||||
->get(route('admin.operations.index'))
|
||||
->get(route('admin.operations.index', ['workspace' => $activeTenant->workspace]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Header Active ManagedEnvironment')
|
||||
->assertDontSee('Header Onboarding ManagedEnvironment')
|
||||
|
||||
@ -44,10 +44,13 @@
|
||||
'provider' => 'microsoft',
|
||||
'module' => 'health_check',
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||
],
|
||||
]);
|
||||
expect($run?->context['target_scope'] ?? [])->toMatchArray([
|
||||
'provider' => 'microsoft',
|
||||
'scope_kind' => 'tenant',
|
||||
'scope_identifier' => $connection->entra_tenant_id,
|
||||
'shared_label' => 'Target scope',
|
||||
])->not->toHaveKey('entra_tenant_id');
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@ -26,18 +28,17 @@ function runQueuedJobThroughMiddleware(object $job, Closure $terminal): mixed
|
||||
it('stores actor-bound execution metadata when verification is queued', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
$connection = ProviderConnection::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->firstOrFail();
|
||||
|
||||
Livewire::test(ListProviderConnections::class)
|
||||
->callTableAction('check_connection', $connection);
|
||||
@ -59,18 +60,17 @@ function runQueuedJobThroughMiddleware(object $job, Closure $terminal): mixed
|
||||
it('blocks verification execution when the initiator loses provider capability before start', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
$connection = ProviderConnection::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->firstOrFail();
|
||||
|
||||
Livewire::test(ListProviderConnections::class)
|
||||
->callTableAction('check_connection', $connection);
|
||||
@ -85,8 +85,10 @@ function runQueuedJobThroughMiddleware(object $job, Closure $terminal): mixed
|
||||
|
||||
expect($capturedJob)->toBeInstanceOf(ProviderConnectionHealthCheckJob::class);
|
||||
|
||||
$user->tenantMemberships()->where('managed_environment_id', $tenant->getKey())->update(['role' => 'readonly']);
|
||||
$user->workspaceMemberships()->where('workspace_id', (int) $tenant->workspace_id)->update(['role' => 'readonly']);
|
||||
app(CapabilityResolver::class)->clearCache();
|
||||
app(WorkspaceCapabilityResolver::class)->clearCache();
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
||||
|
||||
$terminalInvoked = false;
|
||||
|
||||
@ -107,21 +109,20 @@ function (ProviderConnectionHealthCheckJob $job) use (&$terminalInvoked): mixed
|
||||
->and($capturedJob->operationRun?->context['execution_legitimacy']['metadata']['required_capability'] ?? null)->toBe(Capabilities::PROVIDER_RUN);
|
||||
});
|
||||
|
||||
it('blocks verification execution when the initiator loses tenant membership before start', function (): void {
|
||||
it('blocks verification execution when the initiator loses workspace membership before start', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||
'managed_environment_id' => $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'entra_tenant_id' => fake()->uuid(),
|
||||
'consent_status' => 'granted',
|
||||
]);
|
||||
$connection = ProviderConnection::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->firstOrFail();
|
||||
|
||||
Livewire::test(ListProviderConnections::class)
|
||||
->callTableAction('check_connection', $connection);
|
||||
@ -136,8 +137,10 @@ function (ProviderConnectionHealthCheckJob $job) use (&$terminalInvoked): mixed
|
||||
|
||||
expect($capturedJob)->toBeInstanceOf(ProviderConnectionHealthCheckJob::class);
|
||||
|
||||
$user->tenantMemberships()->where('managed_environment_id', $tenant->getKey())->delete();
|
||||
$user->workspaceMemberships()->where('workspace_id', (int) $tenant->workspace_id)->delete();
|
||||
app(CapabilityResolver::class)->clearCache();
|
||||
app(WorkspaceCapabilityResolver::class)->clearCache();
|
||||
app(ManagedEnvironmentAccessScopeResolver::class)->clearCache();
|
||||
|
||||
$terminalInvoked = false;
|
||||
|
||||
|
||||
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Services\Auth\WorkspaceMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('inherits access to all workspace environments when no scope rows exist for the member', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
$decision = app(ManagedEnvironmentAccessScopeResolver::class)->decision($user, $tenant, Capabilities::PROVIDER_VIEW);
|
||||
|
||||
expect($decision->workspaceMember)->toBeTrue()
|
||||
->and($decision->explicitScopeRowsPresent)->toBeFalse()
|
||||
->and($decision->managedEnvironmentAllowed)->toBeTrue()
|
||||
->and($decision->capabilityAllowed)->toBeTrue()
|
||||
->and($decision->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('narrows a workspace member to explicitly scoped environments only', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$allowedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$deniedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
|
||||
ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $allowedTenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'operator',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$resolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||
$deniedDecision = $resolver->decision($user, $deniedTenant, Capabilities::PROVIDER_VIEW);
|
||||
|
||||
expect($resolver->canAccess($user, $allowedTenant))->toBeTrue()
|
||||
->and($resolver->canAccess($user, $deniedTenant))->toBeFalse()
|
||||
->and($deniedDecision->failedBoundary)->toBe('managed_environment_scope')
|
||||
->and($deniedDecision->shouldDenyAsNotFound())->toBeTrue();
|
||||
});
|
||||
|
||||
it('ignores scope rows that belong to another workspace boundary', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$foreignWorkspace = Workspace::factory()->create();
|
||||
$foreignTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $foreignWorkspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
]);
|
||||
|
||||
ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $foreignTenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$decision = app(ManagedEnvironmentAccessScopeResolver::class)->decision($user, $tenant);
|
||||
|
||||
expect($decision->explicitScopeRowsPresent)->toBeFalse()
|
||||
->and($decision->allowed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('removes environment scope rows and invalidates access when workspace membership ends', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$actor = User::factory()->create();
|
||||
$member = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $actor->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
$memberMembership = WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
$scope = ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $member->getKey(),
|
||||
'role' => 'operator',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$resolver = app(ManagedEnvironmentAccessScopeResolver::class);
|
||||
|
||||
expect($resolver->canAccess($member, $tenant))->toBeTrue();
|
||||
|
||||
app(WorkspaceMembershipManager::class)->removeMember($workspace, $actor, $memberMembership);
|
||||
|
||||
expect($resolver->canAccess($member, $tenant))->toBeFalse()
|
||||
->and(ManagedEnvironmentMembership::query()->whereKey($scope->getKey())->exists())->toBeFalse();
|
||||
});
|
||||
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('resolves managed-environment capabilities from workspace membership role', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'readonly',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
expect($resolver->getRole($user, $tenant)?->value)->toBe('manager')
|
||||
->and($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE))->toBeTrue()
|
||||
->and($resolver->can($user, $tenant, Capabilities::TENANT_DELETE))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not fall back to a role-bearing managed-environment membership without workspace membership', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
ManagedEnvironmentMembership::query()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
expect($resolver->isMember($user, $tenant))->toBeFalse()
|
||||
->and($resolver->getRole($user, $tenant))->toBeNull()
|
||||
->and($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE))->toBeFalse();
|
||||
});
|
||||
|
||||
it('logs boundary-safe denied access diagnostics', function (): void {
|
||||
Log::spy();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect(app(CapabilityResolver::class)->can($user, $tenant, Capabilities::PROVIDER_VIEW))->toBeFalse();
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('rbac.denied', Mockery::on(fn (array $context): bool => $context['failed_boundary'] === 'workspace_membership'
|
||||
&& $context['workspace_id'] === (int) $workspace->getKey()
|
||||
&& $context['managed_environment_id'] === (int) $tenant->getKey()
|
||||
&& $context['actor_user_id'] === (int) $user->getKey()
|
||||
&& $context['capability'] === Capabilities::PROVIDER_VIEW))
|
||||
->once();
|
||||
});
|
||||
@ -0,0 +1,48 @@
|
||||
# Requirements Checklist: Workspace-first RBAC & Environment Access Scoping
|
||||
|
||||
## Scope and problem framing
|
||||
|
||||
- [x] The package describes the real repo problem as dual role-bearing authorization truth, not generic missing RBAC.
|
||||
- [x] The package keeps `WorkspaceMembership` as the only role-bearing truth.
|
||||
- [x] The package treats the current `ManagedEnvironmentMembership` semantics as a narrow access-scope overlay or in-place successor only.
|
||||
- [x] The package keeps environment scope optional and narrowing-only.
|
||||
- [x] The package does not absorb provider capability, source taxonomy, copy/localization, or cutover-guardrail work from adjacent specs.
|
||||
|
||||
## Repo-truth anchoring
|
||||
|
||||
- [x] The package reflects the current repo term `ManagedEnvironmentMembership` rather than the stale raw-candidate term `TenantMembership`.
|
||||
- [x] The package references the existing workspace-first seams: `WorkspaceMembership`, `WorkspaceCapabilityResolver`, and `WorkspaceContext`.
|
||||
- [x] The package references the current environment-owned seams that must be retargeted: `CapabilityResolver`, `User::canAccessTenant()`, key policies, and the tenant-membership Filament surfaces.
|
||||
- [x] The package keeps `OperationRun` authorization split between workspace-bound and environment-bound runs.
|
||||
|
||||
## Authorization contract
|
||||
|
||||
- [x] Non-membership or out-of-scope access remains `404`.
|
||||
- [x] In-scope members missing capability remain `403`.
|
||||
- [x] Provider capability and operability remain downstream gates after local RBAC passes.
|
||||
- [x] No scope row can grant access without workspace membership.
|
||||
- [x] No second role selector survives on the managed-environment access-scope surface.
|
||||
- [x] Touched searchable-resource results remain non-member-safe and out-of-scope-safe.
|
||||
- [x] Denied-access diagnostics are modeled as derived, boundary-safe logging rather than new persisted truth.
|
||||
|
||||
## Filament and UI guardrails
|
||||
|
||||
- [x] Filament remains v5 on Livewire v4.
|
||||
- [x] Provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- [x] Touched destructive actions remain `->action(...)` plus `->requiresConfirmation()`.
|
||||
- [x] `ProviderConnectionResource` remains non-globally-searchable and no touched searchable resource loses its valid View or Edit destination.
|
||||
- [x] Asset strategy remains unchanged and does not introduce new `filament:assets` requirements beyond existing deployment expectations.
|
||||
|
||||
## Testing and readiness
|
||||
|
||||
- [x] The package defines bounded proof through unit, feature, and one browser smoke.
|
||||
- [x] The same validation commands appear in `spec.md`, `plan.md`, and `quickstart.md`.
|
||||
- [x] The package states that Specs `280`, `281`, and `283` are external prerequisites for runtime implementation.
|
||||
- [x] The package stays prep-only and does not claim implementation has already landed.
|
||||
|
||||
## Outcome
|
||||
|
||||
- **Review outcome class**: `blocked-by-prerequisites`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Readiness note**: implementation is externally gated until Specs `280`, `281`, and `283` are present on the branch
|
||||
@ -0,0 +1,229 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Workspace-first RBAC & Environment Access Scoping (Logical Contract)
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Logical review contract for Feature 285. These endpoints model the shared
|
||||
access decisions the runtime implementation must be able to answer. They do
|
||||
not require public HTTP exposure in their current form.
|
||||
servers:
|
||||
- url: https://tenantpilot.local/logical
|
||||
paths:
|
||||
/workspaces/{workspaceId}/members/{userId}/authorization:
|
||||
get:
|
||||
operationId: getWorkspaceMembershipAuthorizationSummary
|
||||
summary: Return the canonical workspace-role authorization summary for one member.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
responses:
|
||||
'200':
|
||||
description: Workspace membership summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkspaceMembershipAuthorizationSummary'
|
||||
/workspaces/{workspaceId}/managed-environments/{managedEnvironmentId}/authorization/{userId}:
|
||||
get:
|
||||
operationId: getManagedEnvironmentAuthorizationDecision
|
||||
summary: >-
|
||||
Return the workspace-first authorization decision for one managed
|
||||
environment and one user.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/WorkspaceId'
|
||||
- $ref: '#/components/parameters/ManagedEnvironmentId'
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
- name: requiredCapability
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Existing capability key required by the calling surface.
|
||||
responses:
|
||||
'200':
|
||||
description: Managed-environment authorization decision
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ManagedEnvironmentAuthorizationDecision'
|
||||
/operation-runs/{operationRunId}/authorization/{userId}:
|
||||
get:
|
||||
operationId: getOperationRunAuthorizationDecision
|
||||
summary: Return the workspace-first authorization decision for one operation run.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OperationRunId'
|
||||
- $ref: '#/components/parameters/UserId'
|
||||
responses:
|
||||
'200':
|
||||
description: Operation-run authorization decision
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationRunAuthorizationDecision'
|
||||
components:
|
||||
parameters:
|
||||
WorkspaceId:
|
||||
name: workspaceId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
ManagedEnvironmentId:
|
||||
name: managedEnvironmentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
OperationRunId:
|
||||
name: operationRunId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
UserId:
|
||||
name: userId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
schemas:
|
||||
WorkspaceMembershipAuthorizationSummary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workspace_id
|
||||
- user_id
|
||||
- workspace_member
|
||||
- owner_guarded
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
format: uuid
|
||||
user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
workspace_member:
|
||||
type: boolean
|
||||
workspace_role:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Role value resolved from the canonical workspace membership.
|
||||
owner_guarded:
|
||||
type: boolean
|
||||
description: Indicates whether last-owner protection applies to this member.
|
||||
ManagedEnvironmentAuthorizationDecision:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workspace_id
|
||||
- managed_environment_id
|
||||
- user_id
|
||||
- workspace_member
|
||||
- explicit_scope_rows_present
|
||||
- managed_environment_allowed
|
||||
- capability_allowed
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
format: uuid
|
||||
managed_environment_id:
|
||||
type: string
|
||||
format: uuid
|
||||
user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
workspace_member:
|
||||
type: boolean
|
||||
workspace_role:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
explicit_scope_rows_present:
|
||||
type: boolean
|
||||
description: >-
|
||||
False means the member inherits environment visibility across the
|
||||
currently selectable managed environments in the workspace. True
|
||||
means visibility is narrowed by an allowlist.
|
||||
managed_environment_allowed:
|
||||
type: boolean
|
||||
failed_boundary:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: >-
|
||||
Derived denial boundary such as workspace_membership,
|
||||
managed_environment_scope, or capability when access is denied.
|
||||
required_capability:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
capability_allowed:
|
||||
type: boolean
|
||||
denial_http_status:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
description: 404 for non-membership or out-of-scope access, 403 for missing capability.
|
||||
provider_capability_context:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Optional downstream provider-capability note; local RBAC resolves before this.
|
||||
OperationRunAuthorizationDecision:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- operation_run_id
|
||||
- workspace_id
|
||||
- user_id
|
||||
- workspace_member
|
||||
- managed_environment_allowed
|
||||
- capability_allowed
|
||||
properties:
|
||||
operation_run_id:
|
||||
type: string
|
||||
format: uuid
|
||||
workspace_id:
|
||||
type: string
|
||||
format: uuid
|
||||
managed_environment_id:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
format: uuid
|
||||
user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
workspace_member:
|
||||
type: boolean
|
||||
workspace_role:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
managed_environment_allowed:
|
||||
type: boolean
|
||||
description: >-
|
||||
Always true for workspace-bound runs with no managed environment;
|
||||
otherwise derived from the managed-environment access decision.
|
||||
failed_boundary:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: >-
|
||||
Derived denial boundary such as workspace_membership,
|
||||
managed_environment_scope, or capability when access is denied.
|
||||
required_capability:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
capability_allowed:
|
||||
type: boolean
|
||||
denial_http_status:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
199
specs/285-workspace-rbac-environment-access/data-model.md
Normal file
199
specs/285-workspace-rbac-environment-access/data-model.md
Normal file
@ -0,0 +1,199 @@
|
||||
# Data Model: Workspace-first RBAC & Environment Access Scoping
|
||||
|
||||
## Overview
|
||||
|
||||
Feature `285` does not add a new persisted source of truth. It clarifies and narrows responsibility across the existing membership data.
|
||||
|
||||
## Entities and responsibilities
|
||||
|
||||
### 1. WorkspaceMembership
|
||||
|
||||
**Status**: existing, canonical, role-bearing
|
||||
|
||||
**Responsibility**:
|
||||
|
||||
- defines whether a user belongs to a workspace
|
||||
- defines the single role-bearing authority for capability resolution
|
||||
- remains the source for last-owner protection and workspace membership audit
|
||||
|
||||
**Core attributes**:
|
||||
|
||||
- `workspace_id`
|
||||
- `user_id`
|
||||
- `role`
|
||||
- `created_by_user_id`
|
||||
- timestamps
|
||||
|
||||
**Rules**:
|
||||
|
||||
- every access decision starts here
|
||||
- if no `WorkspaceMembership` exists, the user is out of scope for every workspace-owned and managed-environment-owned surface in that workspace
|
||||
|
||||
### 2. ManagedEnvironmentAccessScope
|
||||
|
||||
**Status**: logical successor to the current role-bearing `ManagedEnvironmentMembership` semantics
|
||||
|
||||
**Responsibility**:
|
||||
|
||||
- optionally narrows which managed environments a workspace member may open
|
||||
- never grants capabilities by itself
|
||||
- never stores a second role authority
|
||||
|
||||
**Expected backing**:
|
||||
|
||||
- the current `managed_environment_memberships` persistence reused or renamed in place
|
||||
- no dual-write and no compatibility read path
|
||||
|
||||
**Required data meaning**:
|
||||
|
||||
- `managed_environment_id`
|
||||
- `user_id`
|
||||
- audit metadata such as creator and timestamps
|
||||
|
||||
**Forbidden data meaning**:
|
||||
|
||||
- role-bearing semantics
|
||||
- capability-bearing semantics
|
||||
- owner semantics separate from workspace ownership
|
||||
|
||||
**Rules**:
|
||||
|
||||
- scope rows are valid only for users who already have a `WorkspaceMembership` in the same workspace
|
||||
- if no scope rows exist for a workspace member, environment visibility is inherited across the currently selectable managed environments in that workspace
|
||||
- if scope rows exist for a workspace member, they form an allowlist
|
||||
|
||||
### 3. ManagedEnvironment
|
||||
|
||||
**Status**: existing, workspace-owned child resource
|
||||
|
||||
**Responsibility**:
|
||||
|
||||
- anchors provider connections, runs, findings, review artifacts, and other environment-owned resources to `workspace_id`
|
||||
- provides the workspace boundary that the scope overlay must match
|
||||
|
||||
**Rules**:
|
||||
|
||||
- every managed-environment access decision must confirm that the environment belongs to the current workspace
|
||||
- scope rows may only reference environments within the member's workspace
|
||||
|
||||
### 4. Environment-owned Resources
|
||||
|
||||
**Status**: existing derived subjects of the access contract
|
||||
|
||||
**In-scope examples**:
|
||||
|
||||
- `ProviderConnection`
|
||||
- `OperationRun`
|
||||
- `Finding`
|
||||
- `EvidenceSnapshot`
|
||||
- `ReviewPack`
|
||||
- `TenantReview`
|
||||
- onboarding drafts and sessions tied to a managed environment
|
||||
|
||||
**Responsibility**:
|
||||
|
||||
- reuse the shared access contract instead of implementing local role logic
|
||||
|
||||
## Derived access decision shapes
|
||||
|
||||
### Workspace membership summary
|
||||
|
||||
Used by membership-management surfaces and policy helpers.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `workspace_id`
|
||||
- `user_id`
|
||||
- `workspace_member` boolean
|
||||
- `workspace_role` string or `null`
|
||||
- `owner_guarded` boolean
|
||||
|
||||
### Managed-environment access decision
|
||||
|
||||
Used by `User::canAccessTenant()`, `WorkspaceContext`, selection flows, and environment-owned policies.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `workspace_id`
|
||||
- `managed_environment_id`
|
||||
- `user_id`
|
||||
- `workspace_role` string or `null`
|
||||
- `workspace_member` boolean
|
||||
- `explicit_scope_rows_present` boolean
|
||||
- `managed_environment_allowed` boolean
|
||||
- `failed_boundary` string or `null`
|
||||
- `required_capability` string or `null`
|
||||
- `capability_allowed` boolean
|
||||
- `denial_http_status` integer or `null`
|
||||
- `provider_capability_context` string or `null`
|
||||
|
||||
**Decision order**:
|
||||
|
||||
1. confirm workspace membership
|
||||
2. confirm the environment belongs to the workspace
|
||||
3. if explicit scope rows exist, require the environment to be in the allowlist
|
||||
4. evaluate required capability from the workspace role
|
||||
5. hand off to downstream provider-capability or operability gates only after local access passes
|
||||
|
||||
### OperationRun access decision
|
||||
|
||||
Used by run drilldowns and monitoring surfaces.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `operation_run_id`
|
||||
- `workspace_id`
|
||||
- `managed_environment_id` nullable
|
||||
- `user_id`
|
||||
- `workspace_role` string or `null`
|
||||
- `workspace_member` boolean
|
||||
- `managed_environment_allowed` boolean
|
||||
- `failed_boundary` string or `null`
|
||||
- `required_capability` string or `null`
|
||||
- `capability_allowed` boolean
|
||||
- `denial_http_status` integer or `null`
|
||||
|
||||
**Decision order**:
|
||||
|
||||
- if the run is workspace-bound, use workspace membership plus required capability
|
||||
- if the run is managed-environment-bound, apply the managed-environment access decision first and then required capability
|
||||
|
||||
## Invariants
|
||||
|
||||
- `WorkspaceMembership` is the only role-bearing source of truth.
|
||||
- no managed-environment scope record may grant access without workspace membership.
|
||||
- no managed-environment scope record may point outside the workspace of the current member.
|
||||
- deleting workspace membership removes or invalidates any managed-environment scope rows for that user in the same workspace.
|
||||
- non-membership or out-of-scope access resolves to `404`.
|
||||
- in-scope members missing the required capability resolve to `403`.
|
||||
- provider capability and operability checks do not run until local access passes.
|
||||
|
||||
## Audit expectations
|
||||
|
||||
- workspace role changes keep using the existing workspace membership audit path
|
||||
- managed-environment scope mutations write audit records that describe scope narrowing or widening, not role reassignment
|
||||
- last-owner protection remains anchored to workspace ownership only
|
||||
|
||||
## Denied-access diagnostic context
|
||||
|
||||
Denied-access logging remains derived rather than persisted.
|
||||
|
||||
**Expected diagnostic fields**:
|
||||
|
||||
- `workspace_id`
|
||||
- `managed_environment_id` nullable
|
||||
- `user_id`
|
||||
- `failed_boundary` as a descriptive string such as `workspace_membership`, `managed_environment_scope`, or `capability`
|
||||
- `required_capability` nullable
|
||||
|
||||
**Rules**:
|
||||
|
||||
- diagnostics must be sufficient to explain whether the denial happened at workspace membership, environment scope, or capability evaluation
|
||||
- diagnostics must not include raw provider payloads, secrets, or implementation-only provider evidence
|
||||
- the shared access decision is the only source from which denial diagnostics are derived
|
||||
|
||||
## Migration notes for implementation
|
||||
|
||||
- runtime implementation may repurpose the current `ManagedEnvironmentMembership` model or replace it in place with a clearer successor, but it must preserve the logical rules above
|
||||
- if the current table name is retained temporarily, the implementation must still remove the old role-bearing meaning in the same slice
|
||||
- no parallel legacy projection is allowed beyond what is strictly required to land the cutover atomically
|
||||
303
specs/285-workspace-rbac-environment-access/plan.md
Normal file
303
specs/285-workspace-rbac-environment-access/plan.md
Normal file
@ -0,0 +1,303 @@
|
||||
## Implementation Plan: Workspace-first RBAC & Environment Access Scoping
|
||||
|
||||
**Branch**: `285-workspace-rbac-environment-access` | **Date**: 2026-05-09 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `specs/285-workspace-rbac-environment-access/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Prepare the reserved workspace-first RBAC slice by collapsing the current dual authorization model into one workspace-first access contract. The implementation path keeps `workspace_memberships` as the sole role-bearing truth, replaces role-bearing `ManagedEnvironmentMembership` semantics with a narrow managed-environment access-scope overlay, retargets `CapabilityResolver`, `WorkspaceContext`, `User::canAccessTenant()`, and the key environment-owned policies to that contract, and migrates the current tenant-membership management surface so it no longer edits a second role system.
|
||||
|
||||
This plan is intentionally narrow. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` remains non-globally-searchable, destructive membership actions stay server-authorized and confirmation-protected, and provider capability or source-taxonomy work from Specs `283` and `284` stays separate. Implementation should start only when Specs `280`, `281`, and `283` are present on the runtime branch.
|
||||
|
||||
## Inherited Baseline / Explicit Delta
|
||||
|
||||
### Inherited baseline
|
||||
|
||||
- `apps/platform/app/Models/WorkspaceMembership.php`, `WorkspaceMembershipManager`, `WorkspaceMembershipPolicy`, `WorkspaceRole`, and `WorkspaceCapabilityResolver` already provide workspace-scoped role truth, audit logging, and last-owner protection.
|
||||
- `apps/platform/app/Models/ManagedEnvironmentMembership.php`, `TenantMembershipManager`, `TenantRole`, `RoleCapabilityMap`, and `CapabilityResolver` still carry a second environment-scoped role truth.
|
||||
- `apps/platform/app/Models/User.php` still treats managed-environment memberships as the source for Filament tenant access, default tenant selection, and `canAccessTenant()`.
|
||||
- `apps/platform/app/Support/Workspaces/WorkspaceContext.php` already treats workspace membership as the first shell boundary but still delegates managed-environment access to `User::canAccessTenant()`.
|
||||
- `apps/platform/app/Policies/ProviderConnectionPolicy.php`, `OperationRunPolicy.php`, `FindingPolicy.php`, `EvidenceSnapshotPolicy.php`, `ReviewPackPolicy.php`, and `TenantReviewPolicy.php` all enforce workspace and managed-environment access differently.
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php` and `TenantMembershipsRelationManager.php` currently expose managed-environment membership CRUD with role selectors.
|
||||
- `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php` already distinguishes workspace-owned and environment-owned capabilities, and `ProviderOperationStartGate` from Spec `283` is expected to remain the downstream provider-capability gate after local RBAC passes.
|
||||
|
||||
### Explicit delta in this plan
|
||||
|
||||
- Make workspace membership the only role-bearing access authority for workspace-owned and managed-environment-owned surfaces.
|
||||
- Replace the current role-bearing meaning of `ManagedEnvironmentMembership` with a narrow access-scope overlay contract. Whether the implementation keeps the current table name or renames it in place is a bounded implementation choice; dual-write or fallback semantics are forbidden.
|
||||
- Rework `CapabilityResolver` so it resolves the current role from workspace membership and uses managed-environment scope only for visibility narrowing.
|
||||
- Rework `User::canAccessTenant()`, `User::getTenants()`, and `WorkspaceContext` so environment accessibility follows the same workspace-first contract.
|
||||
- Retarget key policies and Filament access gates to use that contract consistently.
|
||||
- Migrate the current tenant-membership UI into workspace role management plus managed-environment scope management so operators can no longer assign roles in two places.
|
||||
- Keep provider capability, route-shell, and taxonomy prerequisites explicit instead of silently absorbing them.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12.52
|
||||
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing auth resolvers, policies, and Filament relation-manager surfaces
|
||||
**Storage**: PostgreSQL, reusing existing membership persistence rather than adding a new table
|
||||
**Testing**: Pest unit tests, Pest feature tests, and one Pest browser smoke
|
||||
**Validation Lanes**: fast-feedback, confidence, browser
|
||||
**Target Platform**: Laravel monolith in `apps/platform`
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: preserve request-local auth caching and avoid N+1 membership or scope queries on representative environment-owned lists, run lists, and bulk-authorization preflight
|
||||
**Constraints**: no `/system` RBAC redesign, no new role family, no mandatory environment-level ACL product, no provider-boundary work from Specs `281` to `284`, no copy/localization work from Spec `286`, no no-legacy guardrail pack from Spec `287`, no compatibility shim or dual-write path
|
||||
**Scale/Scope**: one workspace-first authorization cutover over the existing admin panel, managed-environment selection, membership management, and environment-owned policies
|
||||
|
||||
## Likely Affected Repo Surfaces
|
||||
|
||||
- `apps/platform/app/Models/User.php`
|
||||
- `apps/platform/app/Models/WorkspaceMembership.php`
|
||||
- `apps/platform/app/Models/ManagedEnvironmentMembership.php` or its in-slice successor
|
||||
- `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
|
||||
- `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`
|
||||
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
|
||||
- `apps/platform/app/Services/Auth/WorkspaceMembershipManager.php`
|
||||
- `apps/platform/app/Services/Auth/TenantMembershipManager.php` or its in-slice successor
|
||||
- `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
|
||||
- `apps/platform/app/Policies/ProviderConnectionPolicy.php`
|
||||
- `apps/platform/app/Policies/OperationRunPolicy.php`
|
||||
- `apps/platform/app/Policies/FindingPolicy.php`
|
||||
- `apps/platform/app/Policies/EvidenceSnapshotPolicy.php`
|
||||
- `apps/platform/app/Policies/ReviewPackPolicy.php`
|
||||
- `apps/platform/app/Policies/TenantReviewPolicy.php`
|
||||
- `apps/platform/app/Policies/TenantOnboardingSessionPolicy.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||
- `apps/platform/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php`
|
||||
- `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`
|
||||
- `apps/platform/app/Support/Tenants/TenantInteractionLane.php` and `TenantOperabilityService` only if environment scope needs one shared access question instead of duplicated page-local checks
|
||||
- representative proof files under `apps/platform/tests/Unit/Auth/`, `apps/platform/tests/Feature/Auth/`, `apps/platform/tests/Feature/Rbac/`, `apps/platform/tests/Feature/Filament/`, and `apps/platform/tests/Browser/`
|
||||
|
||||
## Filament v5 / Capability Surface Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: all touched Filament work remains on Filament v5 with Livewire v4.
|
||||
- **Provider registration location**: provider registration stays in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
|
||||
- **Global search rule**: `ProviderConnectionResource` remains non-globally-searchable; touched searchable resources such as `TenantResource` and `WorkspaceResource` keep existing valid View destinations and must not gain broken search links.
|
||||
- **Destructive actions**: workspace membership and managed-environment scope removals remain `->action(...)` plus `->requiresConfirmation()` and server-side authorization. No URL-only destructive affordance is introduced.
|
||||
- **Asset strategy**: no new asset registration or `filament:assets` behavior is introduced.
|
||||
|
||||
## Workspace-first Access Contract Fit
|
||||
|
||||
- Keep one canonical access decision shape for environment-owned surfaces:
|
||||
- `workspace_role`
|
||||
- `workspace_member`
|
||||
- `managed_environment_allowed`
|
||||
- `failed_boundary` when access is denied
|
||||
- `required_capability`
|
||||
- `capability_allowed`
|
||||
- `provider_capability_context` only as an optional downstream note after local RBAC passes
|
||||
- downstream provider-capability or operability outcome where applicable
|
||||
- Keep the default environment-access rule explicit: no scope rows means inheritance across the currently selectable managed environments in the workspace; explicit scope rows mean allowlist narrowing only.
|
||||
- Keep managed-environment scope subordinate to workspace membership. No scope row may grant access to a user who lacks workspace membership.
|
||||
- Keep role-to-capability mappings derived from one workspace role value. Reusing the current capability constants is in scope; inventing a new role family is not.
|
||||
- Keep current provider capability and operability checks downstream. This slice determines whether local RBAC allows the request to proceed to those deeper checks.
|
||||
- Keep denied-access diagnostics derived from the shared access decision only. Boundary-safe logs may capture workspace, managed environment, failed boundary, and required capability, but they must not expose raw provider payloads or secrets.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament relation managers plus shared shell/context logic
|
||||
- **Shared-family relevance**: membership management, managed-environment selection, related drilldowns, and page access guards
|
||||
- **State layers in scope**: shell, page, relation manager, modal, remembered context, URL-query
|
||||
- **Audience modes in scope**: operator-MSP, support-platform
|
||||
- **Decision/diagnostic/raw hierarchy plan**: workspace role decision first, managed-environment scope second, deeper diagnostics only when access is denied or narrowed
|
||||
- **Raw/support gating plan**: audit history and raw identifiers stay secondary; debug semantics do not become primary page copy
|
||||
- **One-primary-action / duplicate-truth control**: workspace membership surfaces manage roles; managed-environment scope surfaces manage visibility only; the shell uses one shared access outcome instead of repeating different denial stories on each page
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory until all in-scope policies and selection APIs converge on the same access contract
|
||||
- **Special surface test profiles**: standard-native-filament, global-context-shell, shared-detail-family
|
||||
- **Required tests or manual smoke**: functional-core, state-contract, browser-smoke
|
||||
- **Exception path and spread control**: none; any temporary alias or duplicate role editor becomes a blocker or explicit split
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: workspace membership management, managed-environment scope management, environment selection, environment-owned page policies, and run drilldowns
|
||||
- **Shared abstractions reused**: `WorkspaceCapabilityResolver`, `CapabilityResolver`, `WorkspaceContext`, `OperationRunCapabilityResolver`, `WorkspaceMembershipManager`, and the existing Filament relation-manager patterns
|
||||
- **New abstraction introduced? why?**: one unified workspace-first access-decision path is expected because the repo currently has two partially overlapping resolver stacks
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the current abstractions already own caching, capability checks, and membership queries, but they are split by workspace versus environment truth and therefore cannot answer one canonical access question today
|
||||
- **Bounded deviation / spread control**: no bounded exception is planned; implementation should replace, not wrap, the duplicated role-bearing environment path
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Central contract reused**: `OperationRunCapabilityResolver`, `OperationRunPolicy`, and the current provider-operation start path for downstream provider gating
|
||||
- **Delegated UX behaviors**: workspace-bound versus environment-bound run access, required capability lookup, and downstream provider gating remain delegated to the shared run auth seams
|
||||
- **Surface-owned behavior kept local**: existing run links and monitoring surfaces stay local; only the access decision changes
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: existing central lifecycle mechanism
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no
|
||||
- **Provider-owned seams**: `N/A`
|
||||
- **Platform-core seams**: `N/A`
|
||||
- **Neutral platform terms / contracts preserved**: downstream provider capability inputs from Spec `283` remain provider-neutral and unchanged
|
||||
- **Retained provider-specific semantics and why**: `N/A`
|
||||
- **Bounded extraction or follow-up path**: `N/A`
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation begins and again after design artifacts are complete.*
|
||||
|
||||
- Inventory-first / snapshot truth: PASS. The slice changes local authorization only.
|
||||
- Read/write separation: PASS. Membership and scope mutations are DB-only and audit-backed; no new remote write flow is introduced.
|
||||
- Graph contract path: PASS. No new Graph calls are introduced.
|
||||
- Deterministic capabilities: PASS with implementation condition. Capability results for environment-owned pages must be derivable from workspace role plus explicit scope plus existing capability constants.
|
||||
- RBAC-UX plane separation: PASS. `/admin` versus `/system` remains unchanged.
|
||||
- Workspace isolation: PASS. Workspace membership becomes the canonical first boundary.
|
||||
- Managed-environment isolation: PASS. Managed-environment access remains explicit, but only as a narrowing overlay.
|
||||
- Destructive action discipline: PASS by preservation. Membership and scope removals remain confirmation-protected.
|
||||
- Global search safety: PASS with implementation condition. Searchable resources touched by the cutover must still honor deny-as-not-found for inaccessible environments.
|
||||
- OperationRun / Ops-UX: PASS. The feature reuses existing run access seams.
|
||||
- Data minimization: PASS. No new persistence is added.
|
||||
- Test governance: PASS. Proof stays bounded to unit, feature, and one browser smoke.
|
||||
- Proportionality / no premature abstraction: PASS with implementation condition. The access-decision path must replace the split model rather than sit beside it.
|
||||
- Persisted truth / behavioral state: PASS. No new persisted truth or state family is required.
|
||||
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing native relation managers and shared shell contracts remain the primary surfaces.
|
||||
- Provider boundary: PASS. The slice consumes provider capability context but does not redefine provider/platform boundaries.
|
||||
|
||||
**Gate evaluation**: PASS for artifact quality, with explicit external prerequisites from Specs `280`, `281`, and `283`.
|
||||
|
||||
**Post-design re-check**: PASS when `research.md`, `data-model.md`, `quickstart.md`, `contracts/workspace-rbac-environment-access.logical.openapi.yaml`, `tasks.md`, and `checklists/requirements.md` keep the same workspace-first role truth, optional scope-overlay rule, and proof commands.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit, Feature, Browser
|
||||
- **Affected validation lanes**: fast-feedback, confidence, browser
|
||||
- **Why this lane mix is the narrowest sufficient proof**: resolver and scope inheritance belong in unit tests; policy and Filament access behavior belong in feature tests; one browser smoke proves real shell and membership-surface continuity
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Auth/WorkspaceFirstCapabilityResolverTest.php tests/Unit/Auth/ManagedEnvironmentAccessScopeResolverTest.php)`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php)`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php)`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace memberships, explicit environment-scope rows, managed environments, and representative environment-owned records without widening global test defaults
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any new helper should stay feature-local and opt-in
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none beyond one bounded browser smoke
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief for relation-manager CRUD, plus one global-context-shell smoke for access continuity
|
||||
- **Closing validation and reviewer handoff**: rerun the commands above, verify no policy still uses a role-bearing environment membership path, verify managed-environment scope rows cannot outlive or bypass workspace membership, verify the current tenant-membership surface no longer edits roles, verify representative environment-owned lists, run lists, and bulk-authorization preflight stay query-bounded under the shared contract, verify touched searchable resources do not leak inaccessible environments through search results, verify denied-access logging remains diagnosable without raw provider detail, and verify run access still distinguishes workspace-bound versus environment-bound runs correctly
|
||||
- **Budget / baseline / trend follow-up**: contained feature-local increase only
|
||||
- **Review-stop questions**: did the implementation keep dual role authority, did it introduce a new role family, did environment scope become a second ACL product, did it widen into route or provider taxonomy work, did any shell access path skip the shared contract
|
||||
- **Escalation path**: `document-in-feature` for bounded migration notes only; `reject-or-split` if dual truth remains
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: the remaining adjacent concerns are already tracked as Specs `284`, `286`, and `287`
|
||||
|
||||
## Review Checklist Status
|
||||
|
||||
- **Review checklist artifact**: `checklists/requirements.md`
|
||||
- **Review outcome class**: `blocked-by-prerequisites`
|
||||
- **Workflow outcome**: `keep`
|
||||
- **Test-governance outcome**: `keep`
|
||||
- **Readiness note**: runtime work remains externally gated on Specs `280`, `281`, and `283` being present on the implementation branch
|
||||
|
||||
## Rollout Considerations
|
||||
|
||||
- Land the workspace-first access resolver and the policy retargeting atomically so pages do not alternate between old and new access semantics.
|
||||
- Move the membership-management surface split in the same slice as the resolver change so operators cannot keep editing a second role authority after the backend cutover.
|
||||
- Keep environment scope opt-in and narrow from the start; otherwise the feature could accidentally hard-require scope rows for all members.
|
||||
- Preserve downstream provider capability and operability gates as-is; this slice should stop once local RBAC says the request may proceed.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Reject any implementation that leaves role-bearing capability checks on `ManagedEnvironmentMembership`.
|
||||
- Reject any implementation that introduces dual-write or fallback reads between workspace and environment role truth.
|
||||
- Reject any implementation that adds a second role selector to the environment-scope surface.
|
||||
- Reject any implementation that makes environment scope mandatory for all workspace members.
|
||||
- Reject any implementation that widens into provider-capability, route-shell, source-taxonomy, copy/localization, or guardrail-pack work reserved for adjacent specs.
|
||||
|
||||
## Research & Design Outputs
|
||||
|
||||
- `research.md` records the repo-truth decisions, the scope-overlay rule, and the rejected alternatives.
|
||||
- `data-model.md` captures the canonical workspace role truth, the logical environment-scope overlay, and the shared access-decision payloads.
|
||||
- `quickstart.md` gives reviewers the preconditions, bounded validation flow, and exact commands.
|
||||
- `contracts/workspace-rbac-environment-access.logical.openapi.yaml` models the logical workspace membership summary, managed-environment access decision, and run-access decision payloads.
|
||||
- `checklists/requirements.md` records package readiness, boundedness, and the prerequisite note.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/285-workspace-rbac-environment-access/
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── contracts/
|
||||
│ └── workspace-rbac-environment-access.logical.openapi.yaml
|
||||
├── data-model.md
|
||||
├── plan.md
|
||||
├── quickstart.md
|
||||
├── research.md
|
||||
├── spec.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (expected implementation surfaces)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ └── Resources/
|
||||
│ │ ├── TenantResource/
|
||||
│ │ │ ├── Pages/
|
||||
│ │ │ │ └── ManageTenantMemberships.php
|
||||
│ │ │ └── RelationManagers/
|
||||
│ │ │ └── TenantMembershipsRelationManager.php
|
||||
│ │ └── Workspaces/
|
||||
│ │ └── RelationManagers/
|
||||
│ │ └── WorkspaceMembershipsRelationManager.php
|
||||
│ ├── Models/
|
||||
│ │ ├── User.php
|
||||
│ │ ├── WorkspaceMembership.php
|
||||
│ │ └── ManagedEnvironmentMembership.php
|
||||
│ ├── Policies/
|
||||
│ │ ├── ProviderConnectionPolicy.php
|
||||
│ │ ├── OperationRunPolicy.php
|
||||
│ │ ├── FindingPolicy.php
|
||||
│ │ ├── EvidenceSnapshotPolicy.php
|
||||
│ │ ├── ReviewPackPolicy.php
|
||||
│ │ ├── TenantReviewPolicy.php
|
||||
│ │ └── TenantOnboardingSessionPolicy.php
|
||||
│ ├── Services/
|
||||
│ │ └── Auth/
|
||||
│ │ ├── WorkspaceCapabilityResolver.php
|
||||
│ │ ├── CapabilityResolver.php
|
||||
│ │ ├── WorkspaceMembershipManager.php
|
||||
│ │ └── TenantMembershipManager.php
|
||||
│ └── Support/
|
||||
│ ├── Operations/
|
||||
│ │ └── OperationRunCapabilityResolver.php
|
||||
│ └── Workspaces/
|
||||
│ └── WorkspaceContext.php
|
||||
└── tests/
|
||||
├── Browser/
|
||||
├── Feature/
|
||||
│ ├── Auth/
|
||||
│ ├── Filament/
|
||||
│ └── Rbac/
|
||||
└── Unit/
|
||||
└── Auth/
|
||||
```
|
||||
|
||||
**Structure Decision**: keep the documentation package self-contained under `specs/285-workspace-rbac-environment-access/`; later runtime work should modify the existing auth, policy, model, and Filament seams directly in `apps/platform/` instead of adding a parallel RBAC subsystem.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|---|---|---|
|
||||
| Canonical replacement of existing role-bearing environment membership semantics | The current codebase already has duplicate role truth and cannot satisfy workspace-first tenancy without replacing one side | Synchronizing two role-bearing stores would add more compatibility logic and preserve the drift |
|
||||
| One unified workspace-first access-decision path | Multiple policies and selection APIs need one shared answer for workspace membership plus environment scope | Page-local helpers would keep drift and inconsistent 404/403 semantics |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: the current product answers one authorization question from two role-bearing truth sources, which causes inconsistent page access, duplicated operator administration, and workspace-first cutover drift.
|
||||
- **Existing structure is insufficient because**: the repo already contains both `WorkspaceCapabilityResolver` and environment-scoped `CapabilityResolver`, but they currently resolve different role sources and cannot guarantee one shared answer for user access, context selection, policy checks, search visibility, and run drilldowns.
|
||||
- **Narrowest correct implementation**: retarget the existing shared auth seams so workspace membership is the only role-bearing truth and managed-environment scope becomes narrowing-only, instead of adding a third resolver family, a new role catalog, or a new persistence layer.
|
||||
- **Ownership cost created**: auth services, user helpers, context helpers, policies, relation managers, audits, and the bounded proof suite all need coordinated changes, but the cost is bounded because the slice removes duplicate semantics instead of adding another permanent subsystem.
|
||||
- **Alternative intentionally rejected**: keeping role-bearing managed-environment memberships and synchronizing them with workspace memberships. That would preserve the current ambiguity and add more compatibility logic to a pre-production codebase.
|
||||
- **Release truth**: current-release truth
|
||||
87
specs/285-workspace-rbac-environment-access/quickstart.md
Normal file
87
specs/285-workspace-rbac-environment-access/quickstart.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Quickstart: Workspace-first RBAC & Environment Access Scoping
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this guide to review or implement Feature `285` once the prerequisite specs are present on the working branch.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Spec `280` is present on the branch and provides the workspace-first route or shell baseline.
|
||||
- Spec `281` is present on the branch and provides provider-neutral target-scope baselines.
|
||||
- Spec `283` is present on the branch and provides downstream provider capability context.
|
||||
- The branch does not attempt to absorb Spec `284`, `286`, or `287` work.
|
||||
- The implementation keeps Filament v5 on Livewire v4 and provider registration in `apps/platform/bootstrap/providers.php`.
|
||||
|
||||
If any of the first three prerequisites is missing, stop and land those dependencies first.
|
||||
|
||||
## Read order
|
||||
|
||||
1. `spec.md`
|
||||
2. `plan.md`
|
||||
3. `research.md`
|
||||
4. `data-model.md`
|
||||
5. `contracts/workspace-rbac-environment-access.logical.openapi.yaml`
|
||||
6. `tasks.md`
|
||||
7. `checklists/requirements.md`
|
||||
|
||||
## Implementation intent
|
||||
|
||||
- keep `WorkspaceMembership` as the sole role-bearing truth
|
||||
- reinterpret or replace the current managed-environment membership semantics as a narrow access-scope overlay only
|
||||
- retarget `CapabilityResolver`, `User`, `WorkspaceContext`, and the key environment-owned policies to one workspace-first access contract
|
||||
- split operator-facing membership surfaces into workspace role management and managed-environment access-scope management
|
||||
- preserve 404 for non-members or out-of-scope actors and 403 for in-scope members missing capability
|
||||
- keep touched searchable-resource results and denied-access diagnostics aligned with the same shared access contract
|
||||
|
||||
## Review scenarios
|
||||
|
||||
### Scenario 1: Workspace role alone is sufficient when no explicit environment scope exists
|
||||
|
||||
- create a workspace with at least two managed environments
|
||||
- add a user through workspace membership only
|
||||
- confirm the user can open the allowed environment-owned resources that match their workspace role
|
||||
|
||||
### Scenario 2: Explicit environment scope narrows visibility without changing role
|
||||
|
||||
- keep the same workspace role
|
||||
- add explicit access scope to only one managed environment
|
||||
- confirm the allowed environment remains visible and a sibling environment becomes not found
|
||||
|
||||
### Scenario 3: Membership management surfaces no longer expose duplicate roles
|
||||
|
||||
- open the workspace membership surface and confirm role editing happens there
|
||||
- open the retargeted managed-environment access-scope surface and confirm it manages visibility only
|
||||
|
||||
### Scenario 4: OperationRun access follows the same workspace-first rule
|
||||
|
||||
- confirm a workspace-bound run is viewable from workspace membership plus required capability
|
||||
- confirm an environment-bound run is additionally narrowed by explicit environment scope when present
|
||||
|
||||
### Scenario 5: Search safety and denied-access diagnostics stay aligned
|
||||
|
||||
- confirm any touched searchable resource does not hint inaccessible managed environments to non-members or out-of-scope actors
|
||||
- confirm denied-access logs explain the failed boundary without exposing raw provider data
|
||||
|
||||
### Scenario 6: Representative list and bulk preflight stay query-bounded
|
||||
|
||||
- confirm a representative environment-owned list, run list, and bulk-authorization preflight use the shared access contract without introducing avoidable N+1 membership or scope lookups
|
||||
|
||||
## Planned validation commands
|
||||
|
||||
```bash
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Auth/WorkspaceFirstCapabilityResolverTest.php tests/Unit/Auth/ManagedEnvironmentAccessScopeResolverTest.php)
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php)
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php)
|
||||
|
||||
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)
|
||||
```
|
||||
|
||||
## Expected implementation boundaries
|
||||
|
||||
- no new role family
|
||||
- no dual-write or compatibility fallback
|
||||
- no new provider-boundary contract work
|
||||
- no copy/localization sweep
|
||||
- no cutover-wide guardrail enforcement bundle
|
||||
182
specs/285-workspace-rbac-environment-access/research.md
Normal file
182
specs/285-workspace-rbac-environment-access/research.md
Normal file
@ -0,0 +1,182 @@
|
||||
# Research: Workspace-first RBAC & Environment Access Scoping
|
||||
|
||||
## Scope of this research
|
||||
|
||||
This note records the repo-truth decisions behind Feature `285`. It stays prep-only and does not introduce runtime implementation.
|
||||
|
||||
## Sources reviewed
|
||||
|
||||
- `docs/product/roadmap.md`
|
||||
- `docs/product/spec-candidates.md`
|
||||
- `specs/280-workspace-tenancy-environment-routing/`
|
||||
- `specs/281-provider-connection-scope/`
|
||||
- `specs/283-provider-capability-registry/`
|
||||
- `specs/062-tenant-rbac-v1/`
|
||||
- `specs/065-tenant-rbac-v1/`
|
||||
- `apps/platform/app/Models/WorkspaceMembership.php`
|
||||
- `apps/platform/app/Models/ManagedEnvironmentMembership.php`
|
||||
- `apps/platform/app/Models/User.php`
|
||||
- `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`
|
||||
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
|
||||
- `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
|
||||
- `apps/platform/app/Policies/ProviderConnectionPolicy.php`
|
||||
- `apps/platform/app/Policies/OperationRunPolicy.php`
|
||||
- `apps/platform/app/Policies/FindingPolicy.php`
|
||||
- `apps/platform/app/Policies/EvidenceSnapshotPolicy.php`
|
||||
- `apps/platform/app/Policies/ReviewPackPolicy.php`
|
||||
- `apps/platform/app/Policies/TenantReviewPolicy.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||
|
||||
## Decision 1: Workspace membership is already the right canonical role-bearing seam
|
||||
|
||||
### Findings
|
||||
|
||||
- `WorkspaceMembership` already exists as a workspace-owned role-bearing pivot.
|
||||
- `WorkspaceCapabilityResolver` already resolves workspace membership, caches it per request, and exposes `getRole`, `can`, and `isMember`.
|
||||
- Workspace-owned policies and management flows already rely on that seam.
|
||||
|
||||
### Decision
|
||||
|
||||
`WorkspaceMembership` remains the only role-bearing truth for Feature `285`.
|
||||
|
||||
### Why
|
||||
|
||||
The repo already contains the correct workspace-first substrate. Adding a third RBAC layer or preserving a second role-bearing environment path would increase drift instead of resolving it.
|
||||
|
||||
## Decision 2: The real repo problem is dual role-bearing truth, not missing RBAC from scratch
|
||||
|
||||
### Findings
|
||||
|
||||
- `ManagedEnvironmentMembership` is still treated as a role-bearing pivot.
|
||||
- `CapabilityResolver` still resolves access from managed-environment membership and `RoleCapabilityMap`.
|
||||
- `User::canAccessTenant()` still relies on `CapabilityResolver::isMember()`.
|
||||
- `WorkspaceContext` still reaches managed-environment access through that tenant-first user path.
|
||||
- Key policies for provider connections, runs, findings, evidence, review packs, and tenant reviews use different combinations of workspace membership and managed-environment membership.
|
||||
- `ManageTenantMemberships` and `TenantMembershipsRelationManager` still expose operator-facing role editing for managed environments.
|
||||
|
||||
### Decision
|
||||
|
||||
Feature `285` is a consolidation cutover. It must replace the split role-bearing model rather than stack a new compatibility layer on top.
|
||||
|
||||
### Why
|
||||
|
||||
The raw candidate text talks about `TenantMembership`, but current repo truth uses `ManagedEnvironmentMembership`. The real cutover target is therefore the duplicated role-bearing access model itself.
|
||||
|
||||
## Decision 3: Managed-environment membership becomes a narrow access-scope overlay only
|
||||
|
||||
### Findings
|
||||
|
||||
- The roadmap still wants environment access scoping to remain possible after the workspace-first cutover.
|
||||
- The repo has many environment-owned resources where full workspace inheritance may need narrowing.
|
||||
- Replacing environment role authority does not require removing the concept of selective environment visibility.
|
||||
|
||||
### Decision
|
||||
|
||||
The current managed-environment membership persistence may survive only as a logical access-scope overlay or be replaced in place by an equivalent successor. It must not remain role-bearing.
|
||||
|
||||
### Why
|
||||
|
||||
This preserves the narrow product need for selective environment visibility without rebuilding a second ACL system.
|
||||
|
||||
## Decision 4: The default environment access rule is inheritance, not mandatory per-environment grants
|
||||
|
||||
### Findings
|
||||
|
||||
- Workspace-first tenancy is meant to reduce operator friction, not require duplicate allowlists for every member.
|
||||
- The current product already anchors `ManagedEnvironment` to `workspace_id`.
|
||||
|
||||
### Decision
|
||||
|
||||
If a workspace member has no explicit scope rows, they inherit visibility to the managed environments in that workspace. If scope rows exist, they narrow visibility to an allowlist.
|
||||
|
||||
### Why
|
||||
|
||||
This is the smallest rule that preserves optional narrowing while keeping workspace membership authoritative.
|
||||
|
||||
## Decision 5: `CapabilityResolver` should be retargeted, not replaced by another resolver family
|
||||
|
||||
### Findings
|
||||
|
||||
- `CapabilityResolver` is already the shared entry point for many environment-owned capability checks.
|
||||
- Policies, `User`, and other helpers already call into it.
|
||||
|
||||
### Decision
|
||||
|
||||
Retarget `CapabilityResolver` to derive the current role from workspace membership and use managed-environment scope only for visibility narrowing.
|
||||
|
||||
### Why
|
||||
|
||||
Replacing callers across the repo with a third resolver type would add avoidable migration spread.
|
||||
|
||||
## Decision 6: `User` and `WorkspaceContext` must share the same access contract
|
||||
|
||||
### Findings
|
||||
|
||||
- `User::getTenants()`, `User::getDefaultTenant()`, and `User::canAccessTenant()` currently shape Filament tenant access.
|
||||
- `WorkspaceContext` tracks current workspace, remembered tenant context, and initial workspace resolution, but still delegates tenant access to the old user path.
|
||||
|
||||
### Decision
|
||||
|
||||
`User` and `WorkspaceContext` must consume the same workspace-first access contract so context selection, remembered tenant state, and page policy outcomes cannot drift.
|
||||
|
||||
### Why
|
||||
|
||||
If the shell and the policies resolve access differently, operators will continue to see allowed selection with denied pages or denied selection with allowed deep links.
|
||||
|
||||
## Decision 7: Membership-management UI must split role authority from visibility scope
|
||||
|
||||
### Findings
|
||||
|
||||
- `ManageTenantMemberships` currently titles the page around tenant memberships and says managed-environment access is managed there.
|
||||
- `TenantMembershipsRelationManager` still offers add, change, and remove role actions using tenant capability checks.
|
||||
- Workspace membership management already exists separately.
|
||||
|
||||
### Decision
|
||||
|
||||
The operator-facing role editor remains the workspace membership surface. The current managed-environment membership page is removed or retargeted into access-scope management with no second role selector.
|
||||
|
||||
### Why
|
||||
|
||||
Leaving the current UI intact would let operators recreate the duplicate role model even after backend changes land.
|
||||
|
||||
## Decision 8: OperationRun authorization stays shared but must follow the same workspace-first rule
|
||||
|
||||
### Findings
|
||||
|
||||
- `OperationRunPolicy` currently mixes workspace membership, managed-environment membership, and required capability logic.
|
||||
- Some runs are workspace-bound; others are managed-environment-bound.
|
||||
|
||||
### Decision
|
||||
|
||||
Workspace-bound runs authorize from workspace membership only. Managed-environment-bound runs authorize from workspace membership, then environment scope, then required capability.
|
||||
|
||||
### Why
|
||||
|
||||
This preserves the existing distinction between workspace-wide and environment-bound operations without keeping role truth in two places.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
### Keep both role-bearing membership models and synchronize them
|
||||
|
||||
Rejected because it adds more compatibility logic to a pre-production codebase and preserves the core ambiguity that `285` is meant to remove.
|
||||
|
||||
### Introduce a third access resolver beside the current two
|
||||
|
||||
Rejected because most callers already rely on `CapabilityResolver` or `WorkspaceCapabilityResolver`. A third resolver family would create a longer migration window and more drift.
|
||||
|
||||
### Remove all environment-level scoping entirely
|
||||
|
||||
Rejected because the roadmap explicitly keeps environment access scoping in scope after the workspace-first cutover.
|
||||
|
||||
### Turn environment scope into per-environment role overrides
|
||||
|
||||
Rejected because that would introduce a second ACL product and exceed the reserved scope of `285`.
|
||||
|
||||
## Resulting design constraints
|
||||
|
||||
- No compatibility shim or dual-write path.
|
||||
- No new role family.
|
||||
- No new persisted source of truth.
|
||||
- No widening into provider capability, route-shell, source taxonomy, copy/localization, or cutover-guardrail work reserved for adjacent specs.
|
||||
- The proof set stays bounded to unit, feature, and one browser smoke.
|
||||
329
specs/285-workspace-rbac-environment-access/spec.md
Normal file
329
specs/285-workspace-rbac-environment-access/spec.md
Normal file
@ -0,0 +1,329 @@
|
||||
# Feature Specification: Workspace-first RBAC & Environment Access Scoping
|
||||
|
||||
**Feature Branch**: `285-workspace-rbac-environment-access`
|
||||
**Created**: 2026-05-09
|
||||
**Status**: Blocked by external prerequisites
|
||||
**Input**: User description: "Follow instructions in #prompt:SKILL.md with these arguments: mit 285 weitermachen"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Repo truth already has workspace memberships, managed-environment memberships, a workspace capability resolver, and a tenant capability resolver, but authorization still depends on two parallel role-bearing truths. Operators can be valid workspace members while environment visibility, Filament tenant selection, provider-connection access, run drilldowns, and governance surfaces still hinge on separate `ManagedEnvironmentMembership` rows and a second capability map.
|
||||
- **Today's failure**: access semantics are not consistently workspace-first. `WorkspaceContext`, `User::getTenants()`, `ProviderConnectionPolicy`, `OperationRunPolicy`, `FindingPolicy`, `EvidenceSnapshotPolicy`, `ReviewPackPolicy`, and `TenantReviewPolicy` all mix workspace membership, environment membership, and capability checks differently. That makes deny-as-not-found behavior harder to reason about, duplicates role administration, and blocks a calm future role model such as Workspace Admin versus Customer Viewer because role truth is split before customer-safe roles even exist.
|
||||
- **User-visible improvement**: operators manage role-bearing access once at workspace level, environment visibility becomes an explicit secondary scope instead of a second hidden RBAC core, and tenant-owned pages, provider connections, run drilldowns, evidence, findings, and governance review surfaces all follow the same 404 versus 403 rules.
|
||||
- **Smallest enterprise-capable version**: keep `workspace_memberships` as the only role-bearing access truth, replace role-bearing `ManagedEnvironmentMembership` semantics with a narrow optional managed-environment access-scope overlay, retarget `CapabilityResolver`, `User::canAccessTenant()`, `WorkspaceContext`, and the key environment-owned policies to evaluate workspace role first and environment scope second, and migrate the current tenant-membership management surface so it no longer edits a second role family.
|
||||
- **Explicit non-goals**: no `/system` plane RBAC redesign, no full customer-portal RBAC migration, no mandatory environment-level ACL product, no per-environment role matrix, no new role-productization surface, no provider-capability registry work from Spec `283`, no source-taxonomy work from Spec `284`, no copy/localization neutralization from Spec `286`, no no-legacy guardrail pack from Spec `287`, and no compatibility shim or dual-write path.
|
||||
- **Permanent complexity imported**: one unified workspace-first access-resolution path, one narrow optional managed-environment scope overlay contract, targeted policy rewiring, a migration of the current tenant-membership UI into workspace-role management plus environment-scope management, and focused unit, feature, and browser proof. No new role family and no new independent persisted source of truth are added.
|
||||
- **Why now**: Specs `279` through `283` reserve the workspace-first cutover pack specifically so workspace context, provider-neutral identity, and provider capability truth do not sit on top of tenant-first authorization. Without `285`, the route shell and provider boundary work still rest on a dual RBAC core that keeps the product Microsoft- and tenant-shaped in its access semantics.
|
||||
- **Why not local**: a local policy patch or one-off page helper would leave `User`, `WorkspaceContext`, `CapabilityResolver`, `OperationRunPolicy`, relation managers, and onboarding or provider surfaces inconsistent. The drift is structural and already spans models, policies, Filament navigation, and tests.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: authorization source-of-truth change, cross-cutting policy retargeting, and semantic replacement of an existing role-bearing model. Defense: the repo already carries both workspace and managed-environment role truths. Canonical replacement is safer and narrower than keeping them synchronized through more compatibility logic.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- `/admin/workspaces`
|
||||
- `/admin/workspaces/{record}`
|
||||
- `/admin/provider-connections`
|
||||
- named onboarding routes `admin.onboarding` and `admin.onboarding.draft`
|
||||
- current admin-panel managed-environment selection and tenant-owned routes used by `Finding`, `EvidenceSnapshot`, `ReviewPack`, `TenantReview`, and `OperationRun` drilldowns, regardless of whether the shell is still the current tenant-context route family or the prepared workspace-first route family from Spec `280`
|
||||
- `TenantResource` membership and related-access surfaces that currently expose managed-environment membership CRUD
|
||||
- **Data Ownership**:
|
||||
- `workspace_memberships` remain the workspace-owned, role-bearing source of truth
|
||||
- the current `managed_environment_memberships` persistence becomes a narrow managed-environment access-scope overlay or is replaced in-place by its workspace-first successor; it must not remain a second role-bearing truth
|
||||
- `ManagedEnvironment`, `ProviderConnection`, `Finding`, `EvidenceSnapshot`, `ReviewPack`, and `TenantReview` remain managed-environment-owned records anchored by `workspace_id` plus `managed_environment_id`
|
||||
- `OperationRun` continues to support both workspace-bound records anchored by `workspace_id` alone and managed-environment-bound records anchored by `workspace_id` plus optional `managed_environment_id`
|
||||
- **RBAC**:
|
||||
- workspace membership is the first entitlement boundary
|
||||
- managed-environment access scope is an optional narrowing boundary, not a second independent role authority
|
||||
- capability checks for managed-environment-owned resources continue to use the existing capability registry, but role resolution must come from workspace membership rather than environment membership
|
||||
- non-members or out-of-scope actors stay `404`; in-scope members missing a required capability stay `403`
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: navigation entry points, context selection, relation-manager membership management, cross-resource authorization, run drilldowns, and shared policy enforcement
|
||||
- **Systems touched**: `WorkspaceContext`, `User::managedEnvironments()`, `User::getTenants()`, `User::canAccessTenant()`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `TenantMembershipManager`, `WorkspaceMembershipManager`, `OperationRunCapabilityResolver`, key environment-owned policies, `ManageTenantMemberships`, `TenantMembershipsRelationManager`, workspace membership relation managers, and shared `UiEnforcement` paths that depend on capability outcomes
|
||||
- **Existing pattern(s) to extend**: workspace membership resolution, current capability resolver caching, shared deny-as-not-found policy pattern, existing workspace membership management, and existing action-surface enforcement for membership CRUD
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `WorkspaceCapabilityResolver`, `CapabilityResolver` as the current managed-environment capability entry point, `WorkspaceContext`, `OperationRunCapabilityResolver`, `UiEnforcement`, `WorkspaceMembershipManager`, and the existing Filament relation-manager contract family
|
||||
- **Why the existing shared path is sufficient or insufficient**: the repo already has the right core seams, but they currently stop at workspace-only or environment-only access decisions. The feature should converge them into one shared workspace-first access contract instead of introducing a third resolver stack.
|
||||
- **Allowed deviation and why**: none. Any temporary compatibility alias, duplicate manager, or page-local access resolver would extend the drift this slice is meant to remove.
|
||||
- **Consistency impact**: workspace chooser, managed-environment selection, provider-connections access, environment-owned resource policies, and membership-management surfaces must all derive access from the same workspace-first contract before provider capability or operability checks add deeper gating.
|
||||
- **Review focus**: reviewers must verify that no role-bearing `ManagedEnvironmentMembership` path survives in policies or Filament gates, that workspace membership becomes the only role authority, and that environment scope is modeled as narrowing rather than as a second full RBAC system.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: `OperationRunCapabilityResolver`, `OperationRunPolicy`, `ProviderOperationStartGate`, and the existing `OperationRunService` lifecycle path
|
||||
- **Delegated start/completion UX behaviors**: run viewability, required capability lookups, workspace-versus-environment entitlement resolution, and provider-capability follow-through stay delegated to the shared run authorization seams
|
||||
- **Local surface-owned behavior that remains**: start surfaces keep only initiation inputs and the existing `Open operation` or drilldown affordances; this slice changes the access contract they rely on
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: existing central lifecycle mechanism
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check
|
||||
|
||||
`N/A - no shared provider/platform boundary is redefined in this slice. The feature consumes provider-neutral capability outcomes from Spec 283 as downstream inputs only.`
|
||||
|
||||
## UI / Surface Guardrail Impact
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Workspace membership management | yes | Native Filament resource relation manager | role-bearing membership CRUD, audit-safe owner guards | page, relation manager, modal | no | becomes the only role-management surface |
|
||||
| Managed-environment access-scope management (retargeted from current tenant membership surface) | yes | Native Filament relation manager plus existing page shell | optional environment narrowing, selection visibility, drilldown continuity | page, relation manager, modal | no | must stop acting like a second role editor |
|
||||
| Shared managed-environment selection and tenant-owned page access | yes | Mixed shared shell plus existing native pages | context selection, 404/403 semantics, route continuity, run drilldowns | shell, page, remembered context, URL-query | no | workflow guardrail change more than layout change |
|
||||
|
||||
## Decision-First Surface Role
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Workspace membership management | Primary Decision Surface | Decide which workspace role a user should hold | member, role, and last-owner guard status | audit trail and historical change detail | Primary because this is the canonical role-bearing authority after the cutover | Matches workspace-first SaaS administration | removes duplicate role assignment across workspace and environment surfaces |
|
||||
| Managed-environment access-scope management | Secondary Context Surface | Decide whether a workspace member should be narrowed to a subset of environments | whether access inherits workspace-wide or is explicitly narrowed | exact scoped environments and audit history | Secondary because it narrows visibility after the main workspace role decision | Keeps environment scope subordinate to workspace role truth | avoids forcing operators to reason about two different role systems |
|
||||
| Shared managed-environment selection and tenant-owned page access | Primary Decision Surface | Decide whether the current environment is accessible and what next page can be opened safely | current workspace, selected environment, access-denied routing, one next valid destination | deeper diagnostics, provider capability blockers, and operability detail | Primary because operators feel the cutover first through context selection and page access | Follows the existing admin shell and drilldown workflow | reduces surprise 404/403 drift between pages and runs |
|
||||
|
||||
## Audience-Aware Disclosure
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Workspace membership management | operator-MSP, support-platform | member, role, allowed management actions, owner-guard status | audit detail, membership source, history | raw model identifiers | `Change role` or `Add member` | raw IDs and low-level audit context stay secondary | workspace role is shown once as the canonical authority |
|
||||
| Managed-environment access-scope management | operator-MSP, support-platform | whether access inherits workspace-wide or is explicitly scoped, plus the scoped environments | who is scoped where, why, and audit history | raw pivot identifiers and debug metadata | `Manage access scope` | raw pivot data stays hidden | the page explains environment narrowing once and does not repeat workspace role meaning |
|
||||
| Shared managed-environment selection and tenant-owned page access | operator-MSP, support-platform | selected workspace, selected environment, and whether access is allowed | scoped-access reasons, provider capability or operability follow-up | raw policy internals and debug payloads | `Open environment` or `Return to workspace` | debug semantics stay out of default-visible UI | access denial is explained through one shared contract before deeper diagnostics appear |
|
||||
|
||||
## UI/UX Surface Classification
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Workspace membership management | List / Detail / Settings | CRUD / Relation-first Resource | add member or change role | inline relation-manager management | forbidden | grouped relation actions only | grouped destructive actions with confirmation | workspace detail memberships relation | same workspace detail page | workspace context | Workspace member | canonical role-bearing access | none |
|
||||
| Managed-environment access-scope management | List / Detail / Settings | CRUD / Relation-first Resource | add or remove allowed environments for a workspace member | inline relation-manager or dedicated scoped page | forbidden | grouped relation actions only | grouped destructive actions with confirmation | managed-environment access scope page under current workspace or environment | same access-scope page | workspace and managed-environment context | Environment access scope | whether access is inherited or narrowed | none |
|
||||
| Shared managed-environment selection and tenant-owned page access | Navigation / Drilldown / Context | Global-context Shell | open the current environment safely | explicit environment selection and deep-link entry points | required where the shell already uses row or identifier click | secondary navigation or diagnostics in helper placements | none introduced by this slice | existing admin shell and context chooser | existing tenant-owned page routes and run drilldowns | workspace, managed environment, lifecycle | Managed environment | access allowed or deny-as-not-found boundary | none |
|
||||
|
||||
## Operator Surface Contract
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Workspace membership management | Workspace owner or manager | decide which workspace role a member should hold | relation-manager settings surface | What should this member be allowed to do across the workspace? | member, workspace role, owner-guard state | audit history and membership source | role authority, owner-guard | TenantPilot only | Add member, Change role | Remove member |
|
||||
| Managed-environment access-scope management | Workspace owner or manager | decide whether a member should be limited to specific environments | relation-manager or dedicated scoped page | Should this member inherit workspace-wide access or be narrowed to specific environments? | inheritance mode, selected environments | audit history, scope source | inherited versus narrowed | TenantPilot only | Add allowed environment, Remove allowed environment | Clear or narrow access scope |
|
||||
| Shared managed-environment selection and tenant-owned page access | Workspace operator | decide whether the current environment or run can be opened safely | global-context shell and page access | Can I open this environment, run, or resource from the current workspace? | current workspace, selected environment, and access result | deeper operability or provider blockers | workspace entitlement, environment scope, capability, operability | none | Open environment, Return to workspace | none |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **New source of truth?**: yes, but it is a canonical replacement of the existing role-bearing source-of-truth split rather than a new persisted truth
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: access control is duplicated across workspace roles and managed-environment memberships, which creates inconsistent page access, duplicated administration, and future-role drift.
|
||||
- **Existing structure is insufficient because**: the current structure requires two role-bearing membership tables plus two resolver stacks to answer one authorization question. That contradicts the workspace-first cutover and makes deny-as-not-found rules inconsistent.
|
||||
- **Narrowest correct implementation**: keep workspace memberships as the only role-bearing truth, reinterpret or replace the existing managed-environment membership model as a narrow access-scope overlay, and retarget the existing shared resolvers, policies, and relation managers instead of adding a new RBAC framework.
|
||||
- **Ownership cost**: capability resolution, model semantics, membership management surfaces, policy tests, and browser smoke all need synchronized updates; that cost is bounded because the feature removes a duplicate model instead of adding a third one.
|
||||
- **Alternative intentionally rejected**: keeping both role-bearing tables and adding synchronization or fallback logic. That would preserve the split truth and add more compatibility complexity in a pre-production codebase.
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, dual-write logic, historical-fixture preservation, and compatibility-specific tests are out of scope unless an adjacent prerequisite spec explicitly requires them.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
### External implementation prerequisites
|
||||
|
||||
- Spec `280` must already provide the workspace-first route and panel-shell baseline on the implementation branch before `285` runtime work begins.
|
||||
- Spec `281` must already provide the provider-neutral target-scope and provider-identity baseline so access scope does not hardcode Microsoft-specific scope contracts.
|
||||
- Spec `283` must already provide the provider capability context consumed by provider-backed actions once workspace-first access passes.
|
||||
- Spec `284` remains adjacent but is not a hard blocker for the RBAC cutover as long as `285` does not absorb source-taxonomy work.
|
||||
|
||||
## Testing / Lane / Runtime Impact
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature, Browser
|
||||
- **Validation lane(s)**: fast-feedback, confidence, browser
|
||||
- **Why this classification and these lanes are sufficient**: the resolver and scope-overlay logic need unit proof; policy, Filament access, and environment-selection behavior need feature proof; one browser smoke is required to confirm that workspace role management plus environment-scope management drive real shell access consistently.
|
||||
- **New or expanded test families**: one unit family for workspace-first access resolution and scoped-environment inheritance, one feature family for environment-owned policy retargeting, one feature family for membership-surface migration, one feature family for `OperationRun` access continuity, and one narrow browser smoke for workspace role plus environment-scope behavior
|
||||
- **Fixture / helper cost impact**: moderate because proof needs workspace membership, optional environment-scope records, managed-environment context, and representative provider/run resources without widening global test defaults
|
||||
- **Heavy-family visibility / justification**: one browser smoke only; no heavy-governance family is justified
|
||||
- **Special surface test profile**: standard-native-filament, global-context-shell, shared-detail-family
|
||||
- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for membership management surfaces; one global-context-shell smoke is required for environment selection and page access continuity
|
||||
- **Reviewer handoff**: reviewers must verify that `workspace_memberships` become the sole role-bearing truth, that any surviving managed-environment overlay no longer carries capability authority, that `ProviderConnectionResource` stays non-globally-searchable with View and Edit pages intact, that touched destructive membership actions still use `->action(...)` plus `->requiresConfirmation()`, that Filament remains v5 on Livewire v4, that provider registration stays in `apps/platform/bootstrap/providers.php`, and that the planned tests cover both positive and negative access paths
|
||||
- **Budget / baseline / trend impact**: moderate feature-local increase only
|
||||
- **Escalation needed**: `document-in-feature` if implementation must stage a temporary scope-overlay alias; `reject-or-split` if it keeps dual role authority
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Auth/WorkspaceFirstCapabilityResolverTest.php tests/Unit/Auth/ManagedEnvironmentAccessScopeResolverTest.php)`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php)`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php)`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
|
||||
|
||||
## Candidate Selection Gate Summary
|
||||
|
||||
- **Selected candidate**: `285 - Workspace-first RBAC & Environment Access Scoping`
|
||||
- **Source locations**:
|
||||
- `docs/product/spec-candidates.md` under the reserved workspace-first cutover pack
|
||||
- `docs/product/roadmap.md` under the same cutover ordering
|
||||
- **Why selected now**: the user explicitly requested the reserved slot `285`, and repo truth shows this is the next unspecced cutover gap after workspace context, provider-neutral target scope, and provider capability preparation. The remaining blocker is no longer route or provider vocabulary alone, but the dual authorization core.
|
||||
- **Why close alternatives were deferred**:
|
||||
- `284` remains source-taxonomy work and should not be folded into RBAC replacement
|
||||
- `286` should follow once the canonical access model and environment-scope semantics are stable
|
||||
- `287` should harden the cutover after `285` finishes replacing the dual access core instead of before
|
||||
- **Smallest viable implementation slice**: replace role-bearing managed-environment membership truth with workspace-first role resolution plus an optional environment-scope overlay, then retarget the key policies and membership surfaces around that contract
|
||||
- **Documented deviations from raw candidate wording**:
|
||||
- the raw candidate says `TenantMembership` should be removed or replaced, but current repo truth uses `ManagedEnvironmentMembership` rather than `TenantMembership`
|
||||
- repo truth already has `WorkspaceMembership`, `WorkspaceCapabilityResolver`, and workspace-level role management, so `285` is not greenfield RBAC; it is a consolidation cutover
|
||||
- the optional environment-scope layer should be modeled as a narrow visibility overlay, not as another full role-bearing ACL system
|
||||
|
||||
## Completed-Spec Guardrail Result
|
||||
|
||||
- `specs/279-workspace-managed-environment-core/` already exists with implementation-close-out history and remains historical prerequisite context only
|
||||
- `specs/280-workspace-tenancy-environment-routing/` already exists with `Status: Ready` and remains adjacent prepared context only
|
||||
- `specs/281-provider-connection-scope/` already exists with `Status: Ready` and remains adjacent prepared context only
|
||||
- `specs/282-governance-artifact-retargeting/` already exists with implementation-close-out history and remains historical adjacent context only
|
||||
- `specs/283-provider-capability-registry/` already exists with `Status: Ready` and remains adjacent prepared context only
|
||||
- `specs/062-tenant-rbac-v1/` and `specs/065-tenant-rbac-v1/` remain historical tenant-first RBAC context and are not modified by this package
|
||||
- the target package `specs/285-workspace-rbac-environment-access/` did not exist before this prep run and is the sole new package created here
|
||||
|
||||
## Deferred Adjacent Candidates
|
||||
|
||||
- `284 - Provider-neutral Artifact Source Taxonomy v1`
|
||||
- `286 - UI Copy, IA & Localization Neutralization`
|
||||
- `287 - Cutover Quality Gates & No-Legacy Enforcement`
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Workspace membership becomes the only role authority (Priority: P1)
|
||||
|
||||
As a workspace owner or manager, I want one canonical role-bearing membership record so every managed-environment page and run drilldown inside my workspace follows the same role decision.
|
||||
|
||||
**Why this priority**: this is the core cutover value. Without it, environment-owned pages still depend on a second RBAC truth.
|
||||
|
||||
**Independent Test**: create one workspace member without any explicit managed-environment scope rows, open an entitled managed environment, and confirm provider connections, findings, evidence, review packs, and run drilldowns all use the workspace role consistently.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user has a workspace membership with the required role and no environment-scope narrowing, **When** they open a managed-environment-owned resource inside that workspace, **Then** access is decided from workspace membership plus capability and does not require a second role-bearing environment membership.
|
||||
2. **Given** a user is not a workspace member, **When** they open any managed-environment-owned route or run drilldown for that workspace, **Then** the system responds as deny-as-not-found.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Environment scope can narrow visibility without becoming a second RBAC system (Priority: P1)
|
||||
|
||||
As a workspace owner or manager, I want to optionally narrow a member to specific managed environments without creating a second role matrix per environment.
|
||||
|
||||
**Why this priority**: the roadmap explicitly wants environment access scopes to stay feasible, but not at the cost of reintroducing another full ACL core.
|
||||
|
||||
**Independent Test**: grant one workspace member access to only one environment, confirm that environment is selectable and a sibling environment in the same workspace stays hidden or 404, while the same workspace role capabilities apply inside the allowed environment.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace member has an explicit allowlist for one managed environment, **When** they try to open another managed environment in the same workspace, **Then** the system responds as deny-as-not-found.
|
||||
2. **Given** a workspace member has an explicit allowlist for one managed environment, **When** they open an allowed environment, **Then** their role capabilities are derived from workspace membership and only visibility is narrowed by environment scope.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Membership management surfaces stop editing duplicate role truth (Priority: P2)
|
||||
|
||||
As a workspace owner or manager, I want workspace roles and environment scopes managed on purpose-built surfaces so I do not assign contradictory roles in two places.
|
||||
|
||||
**Why this priority**: the current tenant-membership UI encodes the drift directly; until it is retargeted, operators can keep rebuilding the dual model.
|
||||
|
||||
**Independent Test**: open the workspace membership surface and the retargeted environment-scope surface, confirm roles are managed only at workspace level, confirm environment surfaces only manage visibility scope, and confirm both mutation paths audit their changes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace owner opens membership management, **When** they change a member role, **Then** the role change is stored and audited at workspace scope only.
|
||||
2. **Given** a workspace owner opens the managed-environment access-scope surface, **When** they add or remove one environment from scope, **Then** the mutation changes environment visibility only and does not expose a second role selector.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when a workspace member has no explicit environment-scope rows and the workspace contains archived or removed managed environments?
|
||||
- How does the system handle a remembered managed-environment context after a scope row is removed or a workspace membership is deleted?
|
||||
- What happens when an `OperationRun` belongs to a workspace with no `managed_environment_id` versus one that is environment-bound?
|
||||
- How does the system handle last-owner protection when workspace role management replaces the current managed-environment owner semantics?
|
||||
- What happens when local RBAC passes but provider capability from Spec `283` blocks the action afterward?
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 Workspace membership is the only role-bearing authorization truth.** Managed-environment-owned resource capabilities MUST resolve from workspace membership, not from a second environment role record.
|
||||
- **FR-002 Workspace membership is the first entitlement boundary.** Non-members of the current workspace MUST receive deny-as-not-found for workspace-owned and managed-environment-owned routes, actions, and run drilldowns.
|
||||
- **FR-003 Managed-environment scope is an optional narrowing layer.** The managed-environment overlay MUST only answer whether the current workspace member may access a specific managed environment; it MUST NOT become a second role or capability registry.
|
||||
- **FR-004 Full workspace inheritance is the default.** If no explicit managed-environment scope narrowing exists for a workspace member, that member inherits environment visibility across the selectable managed environments in the workspace.
|
||||
- **FR-005 Scoped access is explicit.** If explicit managed-environment scope rows exist for a workspace member, only those environments are visible or openable for that member.
|
||||
- **FR-006 Capability resolution stays capability-first.** The existing capability registry remains the only capability vocabulary, and environment-owned policies MUST continue to check capabilities rather than raw role strings.
|
||||
- **FR-007 `User::canAccessTenant()`, Filament tenant selection, remembered tenant context, and related-context navigation MUST use the same workspace-first access contract.**
|
||||
- **FR-008 Environment-owned policies MUST be retargeted.** `ManagedEnvironment`, `ProviderConnection`, `OperationRun`, `Finding`, `EvidenceSnapshot`, `ReviewPack`, `TenantReview`, and other governance-review or evidence surfaces in scope MUST evaluate workspace membership first, managed-environment scope second, and capability third.
|
||||
- **FR-009 `OperationRun` authorization MUST preserve mixed workspace and managed-environment behavior.** Workspace-wide runs continue to authorize against workspace membership; environment-bound runs additionally respect managed-environment scope before capability evaluation.
|
||||
- **FR-010 Membership-management surfaces MUST split concerns.** Workspace membership management remains the only role-editing surface. The current tenant-membership surface MUST either be removed or transformed into environment access-scope management with no second role selector.
|
||||
- **FR-011 Membership and scope mutations remain audited.** Workspace role changes, environment-scope changes, and last-owner blocks MUST write canonical audit records with no secrets.
|
||||
- **FR-012 404 versus 403 semantics stay explicit.** Non-membership or out-of-scope access returns `404`; in-scope members missing the capability return `403`.
|
||||
- **FR-013 Global search and shell context stay safe.** Non-members or out-of-scope actors MUST not receive managed-environment hints through global search or remembered tenant context.
|
||||
- **FR-014 No compatibility path is introduced.** The runtime MUST not keep dual-write, fallback-role reads, or legacy role-based `ManagedEnvironmentMembership` checks once the cutover lands.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-001 Resolver performance remains request-local and cacheable.** The new access-resolution path MUST avoid N+1 membership or scope queries in page tables, run lists, and bulk authorization preflight.
|
||||
- **NFR-002 Test breadth stays bounded.** Proof focuses on unit resolver behavior, feature authorization behavior, and one browser smoke; no broad browser matrix is justified.
|
||||
- **NFR-003 Workspace and managed-environment isolation remain explicit and diagnosable.** Audit logs and denied-access logs MUST make the failed boundary diagnosable without leaking raw provider detail.
|
||||
- **NFR-004 Filament v5 and Livewire v4 remain unchanged.** The feature MUST not introduce view publishing, custom action surfaces outside existing patterns, or asset strategy changes.
|
||||
|
||||
## Scope Boundaries *(required for this slice)*
|
||||
|
||||
### In Scope
|
||||
|
||||
- replacing environment role authority with workspace-first role authority
|
||||
- modeling explicit managed-environment scope as narrowing only
|
||||
- retargeting `CapabilityResolver`, `User::canAccessTenant()`, `WorkspaceContext`, and the key environment-owned policies to that contract
|
||||
- migrating the current tenant-membership management surface so it no longer edits a second role family
|
||||
- preserving canonical 404 versus 403 semantics across workspace-owned and environment-owned surfaces
|
||||
- preserving provider-capability follow-through as a downstream gate rather than re-encoding provider rules here
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- designing a new customer-facing role catalog
|
||||
- shipping per-environment role overrides or a full environment ACL matrix
|
||||
- redefining `/system` platform RBAC
|
||||
- absorbing provider capability, source taxonomy, copy/localization, or no-legacy guardrail work from Specs `283`, `284`, `286`, or `287`
|
||||
- shipping compatibility aliases or legacy dual-write logic
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Specs `280`, `281`, and `283` will land or already be available on the eventual implementation branch before runtime work on `285` starts.
|
||||
- `WorkspaceMembership` and `WorkspaceCapabilityResolver` remain the correct extension points for canonical role-bearing access.
|
||||
- The current `managed_environment_memberships` persistence can be repurposed or replaced in place because the product is still pre-production.
|
||||
- The existing capability vocabulary in `App\Support\Auth\Capabilities` remains valid; `285` changes who resolves roles, not the capability names themselves.
|
||||
- `ProviderConnectionResource` remains non-globally-searchable, while resources already carrying valid search destinations retain those destinations.
|
||||
|
||||
## Risks
|
||||
|
||||
- dual-role checks may survive in one or more policies or Filament helpers and silently preserve the old model
|
||||
- environment-scope management could accidentally keep role selectors or owner semantics and reintroduce a second RBAC core under a new label
|
||||
- `OperationRun` access and remembered tenant context may drift if they do not adopt the same workspace-first access contract as page policies
|
||||
- enum or capability-map convergence could widen unexpectedly if implementation tries to solve adjacent role-productization concerns in the same slice
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
**Action Surface Contract satisfied?**: yes
|
||||
**UI-FIL-001 satisfied?**: yes. The slice keeps native Filament relation managers, native confirmation modals, and existing shared enforcement helpers. No local Blade replacement or asset exception is planned.
|
||||
**UX-001 satisfied?**: yes for the touched surfaces in scope. The feature reuses existing resource and relation-manager shells rather than introducing custom Create/Edit/View layouts.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Workspace membership management | `apps/platform/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php` | `Add member` | Inline relation-manager row focus inside the workspace detail page; no redundant `View` action | `Change role`, `Remove member` | none | `Add member` | none | native modal submit and cancel | yes | `Remove member` stays destructive, confirmation-protected, server-authorized, and last-owner-safe |
|
||||
| Managed-environment access-scope management | `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php` plus `TenantMembershipsRelationManager.php` | `Add allowed environment` | Inline relation-manager or page-local scoped list; no separate inspect route | `Edit scope`, `Remove access` | none | `Add allowed environment` | none | native modal submit and cancel | yes | The surface may not expose a role selector, owner semantics, or a second role-bearing mutation path |
|
||||
|
||||
## Key Entities
|
||||
|
||||
- **WorkspaceMembership**: existing workspace-owned, role-bearing membership pivot that remains the only source for role and capability derivation.
|
||||
- **ManagedEnvironmentAccessScope**: logical successor to the current managed-environment membership semantics; stores optional visibility narrowing only and never grants capabilities by itself.
|
||||
- **ManagedEnvironment authorization decision**: derived, non-persisted access payload used by `User`, `WorkspaceContext`, policies, and run drilldowns to evaluate membership, scope, capability, and denial outcome consistently.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In the bounded proof suite, a workspace member with the required workspace role and no explicit scope rows can open at least one provider-connection surface and one governance-artifact surface in the workspace without any role-bearing managed-environment membership row.
|
||||
- **SC-002**: In the bounded proof suite, a workspace member narrowed to one managed environment receives `404` for a sibling environment in the same workspace while still receiving the expected capability outcome inside the allowed environment.
|
||||
- **SC-003**: The retargeted membership surfaces expose exactly one role-editing plane at workspace scope and zero role selectors on the managed-environment scope surface, while every destructive membership or scope mutation remains confirmation-protected.
|
||||
- **SC-004**: The named unit, feature, browser, and dirty-file formatting commands in this package pass without widening the proof family beyond the files listed in `spec.md`, `plan.md`, `quickstart.md`, and `tasks.md`.
|
||||
200
specs/285-workspace-rbac-environment-access/tasks.md
Normal file
200
specs/285-workspace-rbac-environment-access/tasks.md
Normal file
@ -0,0 +1,200 @@
|
||||
---
|
||||
description: "Task list for Workspace-first RBAC & Environment Access Scoping"
|
||||
---
|
||||
|
||||
# Tasks: Workspace-first RBAC & Environment Access Scoping
|
||||
|
||||
**Input**: Design documents from `specs/285-workspace-rbac-environment-access/`
|
||||
**Prerequisites**: `specs/285-workspace-rbac-environment-access/spec.md`, `specs/285-workspace-rbac-environment-access/plan.md`, `specs/285-workspace-rbac-environment-access/checklists/requirements.md`, `specs/285-workspace-rbac-environment-access/research.md`, `specs/285-workspace-rbac-environment-access/data-model.md`, `specs/285-workspace-rbac-environment-access/quickstart.md`, and `specs/285-workspace-rbac-environment-access/contracts/workspace-rbac-environment-access.logical.openapi.yaml`
|
||||
**Implementation Posture**: Runtime implementation completed in this branch.
|
||||
**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Unit/Auth/WorkspaceFirstCapabilityResolverTest.php`, `apps/platform/tests/Unit/Auth/ManagedEnvironmentAccessScopeResolverTest.php`, `apps/platform/tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php`, `apps/platform/tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php`, `apps/platform/tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php`, `apps/platform/tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php`, `apps/platform/tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php`, and `apps/platform/tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php`.
|
||||
**Operations**: No new `OperationRun` family. Reuse `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`, the current `OperationRunPolicy`, existing run links or monitoring surfaces, and the current `OperationRunService` lifecycle ownership.
|
||||
**RBAC**: Workspace membership is the first `404` boundary, managed-environment scope is the second `404` boundary, and in-scope capability denials remain `403`. Provider capability or operability blockers remain downstream of local RBAC and must not be folded into user authorization.
|
||||
**Shared Pattern Reuse**: Reuse `WorkspaceCapabilityResolver`, `CapabilityResolver`, `WorkspaceContext`, `WorkspaceMembershipManager`, `TenantMembershipManager` or its in-slice successor, `OperationRunCapabilityResolver`, and the current Filament relation-manager patterns. Do not add a new role family, a second ACL product, a compatibility shim, or adjacent Spec `284`, `286`, or `287` work.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. `ProviderConnectionResource` remains non-globally-searchable while keeping valid `View` and `Edit` pages. Any touched destructive action must continue to use `->action(...)`, `->requiresConfirmation()`, and current server authorization. Asset strategy stays unchanged.
|
||||
**Compatibility Posture**: Reject dual-write or fallback reads between workspace and environment role truth, reject per-environment role overrides, reject a second role selector on environment-scope surfaces, and keep Specs `284`, `286`, and `287` deferred.
|
||||
**External Prerequisites**: Specs `280`, `281`, and `283` must already be merged or otherwise present on the implementation branch before any runtime or test task starts.
|
||||
**Organization**: Tasks are grouped by user story so workspace-first core authorization, explicit environment-scope narrowing, and operator-facing membership-surface migration remain independently testable.
|
||||
**Review Outcome**: `implemented-and-validated`
|
||||
**Workflow Outcome**: `complete`
|
||||
**Test-governance Outcome**: `keep`
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane.
|
||||
- [x] New or changed tests stay in the named unit, feature, and browser files only.
|
||||
- [x] Workspace, managed-environment, and access-scope fixtures remain explicit and opt-in; no hidden global defaults or compatibility fixtures are planned.
|
||||
- [x] Planned validation commands match `spec.md`, `plan.md`, and `quickstart.md` exactly.
|
||||
- [x] `standard-native-filament`, `global-context-shell`, and shared authorization expectations stay explicit for touched surfaces.
|
||||
- [x] Any attempt to absorb Specs `284`, `286`, or `287` resolves as `split` or `reject-or-split`, not hidden follow-up.
|
||||
|
||||
## Phase 0: External Gate
|
||||
|
||||
**Purpose**: Confirm the prerequisite cutover slices are present before implementation begins.
|
||||
|
||||
- [x] T000 Confirm Specs `280`, `281`, and `283` are already merged or otherwise present on the implementation branch before any runtime or test task begins.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Context)
|
||||
|
||||
**Purpose**: Confirm the exact repo seams, bounded proof files, and deferred-scope posture before runtime edits begin.
|
||||
|
||||
- [x] T001 Review `specs/285-workspace-rbac-environment-access/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/workspace-rbac-environment-access.logical.openapi.yaml` together so implementation stays on Spec `285` only.
|
||||
- [x] T002 [P] Confirm the current workspace-role seams in `apps/platform/app/Models/WorkspaceMembership.php`, `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`, `apps/platform/app/Services/Auth/WorkspaceMembershipManager.php`, and any supporting workspace-role enums or policies before changing shared authorization logic.
|
||||
- [x] T003 [P] Confirm the current managed-environment membership seams in `apps/platform/app/Models/ManagedEnvironmentMembership.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, `apps/platform/app/Services/Auth/TenantMembershipManager.php`, and any supporting tenant-role or role-capability mapping files before retargeting environment access.
|
||||
- [x] T004 [P] Confirm the current user and workspace-context seams in `apps/platform/app/Models/User.php` and `apps/platform/app/Support/Workspaces/WorkspaceContext.php` before retargeting tenant selection, remembered context, and `canAccessTenant()`.
|
||||
- [x] T005 [P] Confirm the current policy and run-authorization seams in `apps/platform/app/Policies/ProviderConnectionPolicy.php`, `apps/platform/app/Policies/OperationRunPolicy.php`, `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Policies/EvidenceSnapshotPolicy.php`, `apps/platform/app/Policies/ReviewPackPolicy.php`, `apps/platform/app/Policies/TenantReviewPolicy.php`, `apps/platform/app/Policies/TenantOnboardingSessionPolicy.php`, and `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php` before changing environment-owned access rules.
|
||||
- [x] T006 [P] Confirm the current operator-facing membership and searchable-resource seams in `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php`, `apps/platform/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`, `apps/platform/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and any touched action helpers so the implementation does not leave duplicate role-editing paths or unsafe searchable results behind.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the proving suite and the canonical workspace-first access contract that all user stories depend on.
|
||||
|
||||
**Critical**: No user-story work should begin until this phase is complete.
|
||||
|
||||
- [x] T007 [P] Add failing coverage in `apps/platform/tests/Unit/Auth/WorkspaceFirstCapabilityResolverTest.php` for workspace-role resolution, per-request caching, capability evaluation without role-bearing managed-environment membership fallback, and boundary-safe denied-access diagnostics.
|
||||
- [x] T008 [P] Add failing coverage in `apps/platform/tests/Unit/Auth/ManagedEnvironmentAccessScopeResolverTest.php` for inherited access when no scope rows exist, allowlist narrowing when scope rows exist, invalid scope rows outside the workspace boundary, and scope-row invalidation when workspace membership ends.
|
||||
- [x] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php` for `User::canAccessTenant()`, `getTenants()`, `getDefaultTenant()`, `WorkspaceContext`, and representative environment-owned list, run-list, and bulk-authorization preflight behavior using one shared workspace-first access contract without avoidable N+1 membership or scope lookups.
|
||||
- [x] T010 [P] Add failing coverage in `apps/platform/tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php`, `apps/platform/tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php`, and `apps/platform/tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php` for `404` versus `403` semantics across provider connections, runs, findings, evidence, review packs, and tenant reviews, including boundary-safe denied-access logging with no raw provider detail.
|
||||
- [x] T011 [P] Add failing coverage in `apps/platform/tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php` and `apps/platform/tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php` for workspace role management staying canonical, last-owner protection remaining workspace-scoped, environment-scope CRUD staying scope-only, and destructive actions preserving confirmation and authorization.
|
||||
- [x] T012 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php` for one workspace owner managing a member role, narrowing that member to one managed environment, and confirming shell access plus one environment-bound drilldown follow the new contract.
|
||||
- [x] T013 Introduce the smallest shared workspace-first access contract under `apps/platform/app/Services/Auth/` by retargeting `WorkspaceCapabilityResolver`, `CapabilityResolver`, and any supporting helper seam so one canonical access decision answers workspace membership, optional environment scope, required capability, search visibility, and boundary-safe denied-access diagnostics without keeping dual role authority.
|
||||
- [x] T014 Update `apps/platform/app/Models/ManagedEnvironmentMembership.php`, `apps/platform/app/Services/Auth/TenantMembershipManager.php`, and any directly cooperating support or audit seams so managed-environment membership semantics become a narrow access-scope overlay with no role-bearing authority and so scope rows are removed or invalidated when workspace membership ends.
|
||||
|
||||
**Checkpoint**: The proving files exist, the shared access contract is explicit, and no later story needs to invent a second authorization path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Workspace membership alone authorizes environment resources (Priority: P1)
|
||||
|
||||
**Goal**: A workspace member with the required role can open environment-owned resources without any second role-bearing managed-environment membership.
|
||||
|
||||
**Independent Test**: Add a user through workspace membership only, open an allowed managed environment in that workspace, and confirm provider connections plus governance artifacts authorize from workspace role plus capability while non-members remain `404`.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T015 [P] [US1] Extend `apps/platform/tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php` after T013-T014 to prove workspace membership alone is sufficient when no explicit environment scope rows exist.
|
||||
- [x] T016 [P] [US1] Extend `apps/platform/tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php` and `apps/platform/tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php` after T013-T014 to prove provider connections, findings, evidence snapshots, review packs, tenant reviews, onboarding sessions, and any touched searchable-resource destinations all authorize from workspace membership first and keep `404` versus `403` semantics honest.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T017 [US1] Update `apps/platform/app/Models/User.php` and `apps/platform/app/Support/Workspaces/WorkspaceContext.php` so tenant selection, default tenant resolution, remembered tenant context, and `canAccessTenant()` all consume the shared workspace-first access contract.
|
||||
- [x] T018 [US1] Update `apps/platform/app/Policies/ProviderConnectionPolicy.php`, `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Policies/EvidenceSnapshotPolicy.php`, `apps/platform/app/Policies/ReviewPackPolicy.php`, `apps/platform/app/Policies/TenantReviewPolicy.php`, `apps/platform/app/Policies/TenantOnboardingSessionPolicy.php`, and any directly touched searchable-resource visibility helpers or queries so workspace membership is the only role-bearing authority for environment-owned resources and inaccessible results stay hidden.
|
||||
|
||||
**Checkpoint**: Environment-owned pages and artifacts no longer require a second role-bearing managed-environment membership to authorize.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Explicit environment scope narrows access without changing roles (Priority: P1)
|
||||
|
||||
**Goal**: Workspace role stays canonical while optional explicit scope rows narrow which managed environments a member may open.
|
||||
|
||||
**Independent Test**: Give a workspace member access to only one managed environment, confirm the allowed environment opens, confirm a sibling environment in the same workspace returns `404`, and confirm the same workspace role capabilities apply inside the allowed environment.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T019 [P] [US2] Extend `apps/platform/tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php` after T013-T014 to prove explicit environment-scope allowlists narrow visibility, stale remembered tenant context is cleared, and missing scope rows default to inheritance across currently selectable managed environments in the workspace.
|
||||
- [x] T020 [P] [US2] Extend `apps/platform/tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php` after T013-T014 to prove workspace-bound runs authorize from workspace membership only while environment-bound runs additionally honor explicit environment scope before capability checks.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T021 [US2] Update `apps/platform/app/Models/User.php`, `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, and any directly touched chooser or tenant-list helpers so explicit environment-scope rows narrow selection visibility and stale out-of-scope remembered environments are cleared.
|
||||
- [x] T022 [US2] Update `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`, `apps/platform/app/Policies/OperationRunPolicy.php`, and any directly touched run-link or monitoring helpers so workspace-bound and environment-bound runs follow the same workspace-first access contract.
|
||||
|
||||
**Checkpoint**: Environment scope acts only as visibility narrowing, and mixed workspace-bound versus environment-bound runs still authorize correctly.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Membership management surfaces stop editing duplicate role truth (Priority: P2)
|
||||
|
||||
**Goal**: Operators manage workspace roles in one place and managed-environment visibility in another, with no second role selector on the environment surface.
|
||||
|
||||
**Independent Test**: Open workspace membership management and the retargeted managed-environment access-scope surface, confirm roles are edited only at workspace level, confirm environment scope CRUD never exposes a role selector, and confirm destructive actions remain confirmation-protected.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T023 [P] [US3] Extend `apps/platform/tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php` after T013-T014 to prove workspace membership remains the sole role editor, last-owner protection stays anchored at workspace scope, and audit logging remains intact.
|
||||
- [x] T024 [P] [US3] Extend `apps/platform/tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php` after T013-T014 to prove the retargeted managed-environment surface manages visibility scope only, never shows a second role selector, keeps destructive mutations behind `->requiresConfirmation()`, and writes audit records for scope add, remove, and clear or narrowing actions.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T025 [US3] Update `apps/platform/app/Services/Auth/WorkspaceMembershipManager.php`, `apps/platform/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php`, and any directly touched workspace-role actions so workspace membership remains the only operator-facing role-editing path.
|
||||
- [x] T026 [US3] Retarget `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php`, `apps/platform/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`, and any directly touched action or label helpers so the surface becomes managed-environment access-scope management with no second role authority.
|
||||
|
||||
**Checkpoint**: The operator-facing UI can no longer recreate dual role truth after the backend cutover.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Validation
|
||||
|
||||
**Purpose**: Run the exact bounded proof set, review the touched auth and Filament seams, and confirm the slice stayed inside Spec `285`.
|
||||
|
||||
- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Auth/WorkspaceFirstCapabilityResolverTest.php tests/Unit/Auth/ManagedEnvironmentAccessScopeResolverTest.php)`.
|
||||
- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/WorkspaceFirstManagedEnvironmentAccessTest.php tests/Feature/Rbac/ProviderConnectionWorkspaceFirstPolicyTest.php tests/Feature/Rbac/OperationRunWorkspaceFirstAuthorizationTest.php tests/Feature/Rbac/GovernanceArtifactsWorkspaceFirstAuthorizationTest.php tests/Feature/Filament/WorkspaceMembershipRoleManagementTest.php tests/Feature/Filament/ManagedEnvironmentAccessScopeManagementTest.php)`.
|
||||
- [x] T029 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec285WorkspaceRbacEnvironmentAccessSmokeTest.php)`.
|
||||
- [x] T030 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`.
|
||||
- [x] T031 [P] Review `apps/platform/app/Models/User.php`, `apps/platform/app/Models/WorkspaceMembership.php`, `apps/platform/app/Models/ManagedEnvironmentMembership.php`, `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, `apps/platform/app/Services/Auth/WorkspaceMembershipManager.php`, `apps/platform/app/Services/Auth/TenantMembershipManager.php`, `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, `apps/platform/app/Policies/ProviderConnectionPolicy.php`, `apps/platform/app/Policies/OperationRunPolicy.php`, `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Policies/EvidenceSnapshotPolicy.php`, `apps/platform/app/Policies/ReviewPackPolicy.php`, `apps/platform/app/Policies/TenantReviewPolicy.php`, `apps/platform/app/Policies/TenantOnboardingSessionPolicy.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ManageTenantMemberships.php`, `apps/platform/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`, `apps/platform/app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php`, `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/Workspaces/WorkspaceResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/bootstrap/providers.php` to confirm Filament v5 / Livewire v4 compliance, unchanged provider-registration location, truthful non-global-search posture, preserved destructive-action confirmation plus authorization, boundary-safe denied-access diagnostics, unchanged asset strategy, and Specs `284`, `286`, and `287` staying deferred.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 0 (External Gate)**: no dependencies; complete before implementation starts.
|
||||
- **Phase 1 (Setup)**: depends on Phase 0.
|
||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all story work.
|
||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes workspace-first authorization for environment-owned resources.
|
||||
- **Phase 4 (US2)**: depends on Phase 2 and should ship with or immediately after US1 so explicit environment narrowing lands on the final shared access contract.
|
||||
- **Phase 5 (US3)**: depends on Phase 2 and should land after or with US1 and US2 so UI semantics match the backend contract.
|
||||
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independently testable after Phase 2 and is the first required increment.
|
||||
- **US2 (P1)**: independently testable after Phase 2, but should ship after or with US1 because explicit scope narrowing depends on the final shared workspace-first access contract.
|
||||
- **US3 (P2)**: independently testable after Phase 2, but should land after or with US1 and US2 so the UI reflects the final backend semantics instead of an intermediate state.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or extend the listed Pest coverage first and make it fail for the intended gap.
|
||||
- Apply the smallest shared-seam changes needed to satisfy the story without reopening Specs `284`, `286`, or `287`.
|
||||
- Re-run the narrowest relevant validation command for that story before moving to the next story.
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
- **Setup**: T002 through T006 can run in parallel once T000 and T001 fix the bounded scope.
|
||||
- **Foundational**: T007 through T012 can run in parallel before T013 and T014 converge the shared access contract.
|
||||
- **US1**: T015 and T016 can run in parallel; T017 and T018 should merge serially around shared user, context, and policy files.
|
||||
- **US2**: T019 and T020 can run in parallel; T021 and T022 should merge serially around shared tenant-access and run-authorization seams.
|
||||
- **US3**: T023 and T024 can run in parallel; T025 and T026 should merge serially around shared Filament membership surfaces.
|
||||
- **Polish**: T027 through T030 can run in parallel after implementation is complete; T031 should close the bounded-scope review last.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **US1 + US2 + US3**. The spec is not complete until the operator-facing role and scope surfaces are split in the same workspace-first model.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Phase 0, Phase 1, and Phase 2.
|
||||
2. Deliver US1 so workspace membership alone authorizes environment-owned resources.
|
||||
3. Deliver US2 so optional explicit environment scope narrows visibility without changing role semantics.
|
||||
4. Deliver US3 so operators can no longer edit duplicate role truth in the UI.
|
||||
5. Finish with the exact validation commands and final bounded-scope review in Phase 6.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Parallelize the failing test work first.
|
||||
2. Serialize merges around `apps/platform/app/Services/Auth/`, `apps/platform/app/Models/User.php`, `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, `apps/platform/app/Policies/`, and the touched Filament relation managers to avoid conflicting contract-shape edits.
|
||||
3. Reject any implementation branch that introduces a second role family, compatibility fallbacks, provider-boundary work, or adjacent-spec cutover work.
|
||||
|
||||
## Deferred Follow-Ups / Non-Goals
|
||||
|
||||
- Spec `284` provider-neutral artifact source taxonomy work
|
||||
- Spec `286` broader UI copy, IA, and localization neutralization
|
||||
- Spec `287` cutover quality gates and no-legacy enforcement beyond this bounded RBAC slice
|
||||
Loading…
Reference in New Issue
Block a user