feat: add tenant governance aggregate contract and action surface follow-ups #199

Merged
ahmido merged 6 commits from 168-tenant-governance-aggregate-contract into dev 2026-03-29 21:14:18 +00:00
4 changed files with 37 additions and 18 deletions
Showing only changes of commit 4c174c717b - Show all commits

View File

@ -131,7 +131,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView) return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).') ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary workflow actions are grouped under "More"; the only inline row action is the related-record drill-down.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".') ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.') ->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
@ -1643,6 +1643,7 @@ public static function reopenAction(): Actions\Action
*/ */
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
{ {
$pageRecord = $record;
$record = static::resolveProtectedFindingRecordOrFail($record); $record = static::resolveProtectedFindingRecordOrFail($record);
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user(); $user = auth()->user();
@ -1671,6 +1672,8 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
try { try {
$callback($record, $tenant, $user); $callback($record, $tenant, $user);
$pageRecord->refresh();
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
Notification::make() Notification::make()
->title('Workflow action failed') ->title('Workflow action failed')

View File

@ -108,7 +108,9 @@ private function getMembership(User $user, Tenant $tenant): ?array
/** /**
* Prime membership cache for a set of tenants in one query. * Prime membership cache for a set of tenants in one query.
* *
* Used to avoid N+1 queries for bulk selection authorization. * Used to avoid N+1 queries for bulk selection authorization while still
* reflecting membership changes that may have happened earlier in the same
* request or test process.
* *
* @param array<int, int|string> $tenantIds * @param array<int, int|string> $tenantIds
*/ */
@ -120,26 +122,14 @@ public function primeMemberships(User $user, array $tenantIds): void
return; return;
} }
$missingTenantIds = [];
foreach ($tenantIds as $tenantId) {
$cacheKey = "membership_{$user->id}_{$tenantId}";
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
$missingTenantIds[] = $tenantId;
}
}
if ($missingTenantIds === []) {
return;
}
$memberships = TenantMembership::query() $memberships = TenantMembership::query()
->where('user_id', $user->id) ->where('user_id', $user->id)
->whereIn('tenant_id', $missingTenantIds) ->whereIn('tenant_id', $tenantIds)
->get(['tenant_id', 'role', 'source', 'source_ref']); ->get(['tenant_id', 'role', 'source', 'source_ref']);
$byTenantId = $memberships->keyBy('tenant_id'); $byTenantId = $memberships->keyBy('tenant_id');
foreach ($missingTenantIds as $tenantId) { foreach ($tenantIds as $tenantId) {
$cacheKey = "membership_{$user->id}_{$tenantId}"; $cacheKey = "membership_{$user->id}_{$tenantId}";
$membership = $byTenantId->get($tenantId); $membership = $byTenantId->get($tenantId);
$this->resolvedMemberships[$cacheKey] = $membership?->toArray(); $this->resolvedMemberships[$cacheKey] = $membership?->toArray();

View File

@ -5,12 +5,15 @@
namespace App\Services\TenantReviews; namespace App\Services\TenantReviews;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\ReviewPack;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantReview; use App\Models\TenantReview;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
@ -20,6 +23,7 @@ public function __construct(
private readonly TenantReviewReadinessGate $readinessGate, private readonly TenantReviewReadinessGate $readinessGate,
private readonly TenantReviewService $reviewService, private readonly TenantReviewService $reviewService,
private readonly WorkspaceAuditLogger $auditLogger, private readonly WorkspaceAuditLogger $auditLogger,
private readonly RequestScopedDerivedStateStore $derivedStateStore,
) {} ) {}
public function publish(TenantReview $review, User $user): TenantReview public function publish(TenantReview $review, User $user): TenantReview
@ -64,6 +68,8 @@ public function publish(TenantReview $review, User $user): TenantReview
tenant: $tenant, tenant: $tenant,
); );
$this->invalidateArtifactTruthCache($review);
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']); return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
} }
@ -104,6 +110,8 @@ public function archive(TenantReview $review, User $user): TenantReview
tenant: $tenant, tenant: $tenant,
); );
$this->invalidateArtifactTruthCache($review);
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']); return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
} }
@ -126,7 +134,7 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.'); throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.');
} }
return DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview { $nextReview = DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview {
$nextReview = $this->reviewService->create($tenant, $snapshot, $user); $nextReview = $this->reviewService->create($tenant, $snapshot, $user);
if ((int) $nextReview->getKey() !== (int) $review->getKey()) { if ((int) $nextReview->getKey() !== (int) $review->getKey()) {
@ -156,5 +164,23 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']); return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
}); });
$this->invalidateArtifactTruthCache($review);
$this->invalidateArtifactTruthCache($nextReview);
return $nextReview;
}
private function invalidateArtifactTruthCache(TenantReview $review): void
{
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $review, 'tenant_review');
$review->loadMissing('currentExportReviewPack');
$pack = $review->currentExportReviewPack;
if ($pack instanceof ReviewPack) {
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $pack, 'review_pack');
}
} }
} }

View File

@ -58,7 +58,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
secondaryLabel: 'Backup set #'.$backupSet->getKey(), secondaryLabel: 'Backup set #'.$backupSet->getKey(),
linkTarget: new ReferenceLinkTarget( linkTarget: new ReferenceLinkTarget(
targetKind: ReferenceClass::BackupSet->value, targetKind: ReferenceClass::BackupSet->value,
url: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant), url: BackupSetResource::getUrl('view', ['record' => $backupSet], panel: 'tenant', tenant: $backupSet->tenant),
actionLabel: 'View backup set', actionLabel: 'View backup set',
contextBadge: 'Tenant', contextBadge: 'Tenant',
), ),