feat: add Exchange credential permission evidence readiness (#500)
Validation: sail artisan test --filter=Spec433|Spec432|Spec431 --compact; pint --dirty --test; git diff --check Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #500
This commit is contained in:
parent
f4e342121a
commit
a6f4529941
@ -108,4 +108,10 @@ TENANTPILOT_BASELINE_FULL_CONTENT_CAPTURE_ENABLED=false
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_ITEMS_PER_RUN=200
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_CONCURRENCY=5
|
||||
TENANTPILOT_BASELINE_EVIDENCE_MAX_RETRIES=3
|
||||
|
||||
# Exchange PowerShell runner gates (disabled by default)
|
||||
TENANTPILOT_EXCHANGE_POWERSHELL_INVOCATION_ENABLED=false
|
||||
TENANTPILOT_EXCHANGE_POWERSHELL_PRODUCTION_RUNNER_ENABLED=false
|
||||
TENANTPILOT_EXCHANGE_POWERSHELL_SUPPORTED_REFERENCE_KINDS=
|
||||
TENANTPILOT_EXCHANGE_POWERSHELL_PERMISSION_EVIDENCE_CURRENTNESS_DAYS=30
|
||||
TENANTPILOT_BASELINE_EVIDENCE_RETENTION_DAYS=90
|
||||
|
||||
@ -45,7 +45,7 @@ public function resolve(ProviderConnection $connection): ExchangePowerShellGateR
|
||||
}
|
||||
|
||||
if ($kind === ProviderCredentialKind::Certificate->value) {
|
||||
if (! $credential->last_rotated_at instanceof Carbon) {
|
||||
if (! $credential->last_rotated_at instanceof Carbon || ! $credential->expires_at instanceof Carbon) {
|
||||
return $this->blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderCredentialMissing,
|
||||
failureCode: 'credential_blocked_certificate_missing',
|
||||
|
||||
@ -756,6 +756,8 @@ private function safeRunnerResultContext(ExchangePowerShellInvocationResult $res
|
||||
foreach ([
|
||||
'runner_mode',
|
||||
'execution_enabled',
|
||||
'readiness_state',
|
||||
'readiness_blocker',
|
||||
'failure_mode',
|
||||
'retryable',
|
||||
'runtime_state',
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantConfiguration;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
|
||||
final class ExchangePowerShellInvocationReadinessEvaluator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExchangePowerShellCredentialReferenceResolver $credentials,
|
||||
private readonly ExchangePowerShellPermissionEvidenceEvaluator $permissions,
|
||||
) {}
|
||||
|
||||
public function evaluate(ManagedEnvironment $environment, ProviderConnection $connection): ExchangePowerShellGateResult
|
||||
{
|
||||
if (! $this->scopeMatches($environment, $connection)) {
|
||||
return ExchangePowerShellGateResult::blocked(
|
||||
reasonCode: ProviderReasonCodes::TenantTargetMismatch,
|
||||
failureCode: 'readiness_blocked_scope_mismatch',
|
||||
message: 'Exchange PowerShell invocation readiness requires a matching workspace, environment, and provider connection.',
|
||||
context: [
|
||||
'readiness_state' => 'blocked',
|
||||
'readiness_blocker' => 'scope',
|
||||
'scope_state' => 'mismatch',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$credentialGate = $this->credentials->resolve($connection);
|
||||
|
||||
if (! $credentialGate->allowed) {
|
||||
return $this->blockedFromGate($credentialGate, 'credential');
|
||||
}
|
||||
|
||||
$permissionGate = $this->permissions->evaluate($environment, $connection);
|
||||
|
||||
if (! $permissionGate->allowed) {
|
||||
return $this->blockedFromGate($permissionGate, 'permission');
|
||||
}
|
||||
|
||||
return ExchangePowerShellGateResult::allowed([
|
||||
'readiness_state' => 'ready_for_live_invocation',
|
||||
'credential_state' => $credentialGate->context['credential_state'] ?? 'ready',
|
||||
'permission_evidence_state' => $permissionGate->context['permission_evidence_state'] ?? 'verified',
|
||||
'permission_currentness_days' => $permissionGate->context['permission_currentness_days'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function scopeMatches(ManagedEnvironment $environment, ProviderConnection $connection): bool
|
||||
{
|
||||
return (int) $connection->workspace_id === (int) $environment->workspace_id
|
||||
&& (int) $connection->managed_environment_id === (int) $environment->getKey();
|
||||
}
|
||||
|
||||
private function blockedFromGate(ExchangePowerShellGateResult $gate, string $blocker): ExchangePowerShellGateResult
|
||||
{
|
||||
return ExchangePowerShellGateResult::blocked(
|
||||
reasonCode: $gate->reasonCode ?? ProviderReasonCodes::ProviderBindingUnsupported,
|
||||
failureCode: $gate->failureCode ?? 'readiness_blocked',
|
||||
message: $gate->message ?? 'Exchange PowerShell invocation readiness was blocked.',
|
||||
context: [
|
||||
'readiness_state' => 'blocked',
|
||||
'readiness_blocker' => $blocker,
|
||||
...$this->safeContext($gate->context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function safeContext(array $context): array
|
||||
{
|
||||
return array_filter(
|
||||
$context,
|
||||
static fn (mixed $value): bool => is_scalar($value) || $value === null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,13 +8,30 @@
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Throwable;
|
||||
|
||||
final class ExchangePowerShellPermissionEvidenceEvaluator
|
||||
{
|
||||
private const string PERMISSION_KEY = 'Exchange.ManageAsApp';
|
||||
|
||||
private const string TRUSTED_EVIDENCE_SOURCE = 'provider_verification';
|
||||
|
||||
private const string EVIDENCE_EVALUATOR = 'exchange_powershell_permission_evidence';
|
||||
|
||||
private const string EVIDENCE_EVALUATOR_VERSION = 'v1';
|
||||
|
||||
public function evaluate(ManagedEnvironment $environment, ProviderConnection $connection): ExchangePowerShellGateResult
|
||||
{
|
||||
if (trim((string) $connection->provider) !== 'microsoft') {
|
||||
return $this->blocked(
|
||||
failureCode: 'permission_evidence_blocked_unsupported_provider',
|
||||
stateKey: 'permission_evidence_state',
|
||||
state: 'unsupported_provider',
|
||||
context: ['provider' => 'unsupported'],
|
||||
);
|
||||
}
|
||||
|
||||
$permission = ManagedEnvironmentPermission::query()
|
||||
->where('workspace_id', (int) $environment->workspace_id)
|
||||
->where('managed_environment_id', (int) $environment->getKey())
|
||||
@ -28,28 +45,47 @@ public function evaluate(ManagedEnvironment $environment, ProviderConnection $co
|
||||
}
|
||||
|
||||
$details = is_array($permission->details) ? $permission->details : [];
|
||||
$status = $this->normalizedStatus($permission->status);
|
||||
|
||||
if ((string) $permission->status !== 'granted') {
|
||||
if ($status !== 'granted') {
|
||||
return match ($status) {
|
||||
'missing', 'absent', 'revoked' => $this->blocked('permission_evidence_blocked_absent', 'permission_evidence_state', 'absent', $permission),
|
||||
'unknown', 'error' => $this->blocked('permission_evidence_blocked_unknown', 'permission_evidence_state', 'unknown', $permission),
|
||||
default => $this->blocked('permission_evidence_blocked_unvalidated', 'permission_evidence_state', 'unvalidated', $permission),
|
||||
};
|
||||
}
|
||||
|
||||
$metadata = $this->validatedEvidenceMetadata($details);
|
||||
|
||||
if ($metadata === null) {
|
||||
return $this->blocked('permission_evidence_blocked_unvalidated', 'permission_evidence_state', 'unvalidated', $permission);
|
||||
}
|
||||
|
||||
if (! $permission->last_checked_at || $permission->last_checked_at->lt(now()->subDays(30))) {
|
||||
if ($this->permissionEvidenceIsStale($permission, $metadata)) {
|
||||
return $this->blocked('permission_evidence_blocked_stale', 'permission_evidence_state', 'stale', $permission);
|
||||
}
|
||||
|
||||
if ((int) ($details['workspace_id'] ?? 0) !== (int) $connection->workspace_id) {
|
||||
$scopeIds = $this->validatedScopeIds($details);
|
||||
|
||||
if ($scopeIds === null) {
|
||||
return $this->blocked('permission_evidence_blocked_unvalidated', 'permission_evidence_state', 'unvalidated', $permission);
|
||||
}
|
||||
|
||||
if ($scopeIds['workspace_id'] !== (int) $connection->workspace_id) {
|
||||
return $this->blocked('permission_evidence_blocked_wrong_workspace', 'permission_evidence_state', 'wrong_workspace', $permission);
|
||||
}
|
||||
|
||||
if ((int) ($details['managed_environment_id'] ?? 0) !== (int) $connection->managed_environment_id) {
|
||||
if ($scopeIds['managed_environment_id'] !== (int) $connection->managed_environment_id) {
|
||||
return $this->blocked('permission_evidence_blocked_wrong_environment', 'permission_evidence_state', 'wrong_environment', $permission);
|
||||
}
|
||||
|
||||
if ((string) ($details['provider'] ?? '') !== (string) $connection->provider) {
|
||||
if (trim((string) ($details['provider'] ?? '')) !== 'microsoft'
|
||||
|| trim((string) ($details['provider'] ?? '')) !== trim((string) $connection->provider)
|
||||
) {
|
||||
return $this->blocked('permission_evidence_blocked_unsupported_provider', 'permission_evidence_state', 'unsupported_provider', $permission);
|
||||
}
|
||||
|
||||
if ((int) ($details['provider_connection_id'] ?? 0) !== (int) $connection->getKey()) {
|
||||
if ($scopeIds['provider_connection_id'] !== (int) $connection->getKey()) {
|
||||
return $this->blocked('permission_evidence_blocked_wrong_provider_connection', 'permission_evidence_state', 'wrong_provider_connection', $permission);
|
||||
}
|
||||
|
||||
@ -58,14 +94,181 @@ public function evaluate(ManagedEnvironment $environment, ProviderConnection $co
|
||||
'permission_key' => self::PERMISSION_KEY,
|
||||
'permission_evidence_id' => (int) $permission->getKey(),
|
||||
'last_checked_at' => $permission->last_checked_at?->toJSON(),
|
||||
'permission_currentness_days' => $this->currentnessDays(),
|
||||
'permission_evidence_source' => $metadata['source'],
|
||||
'permission_observed_at' => $metadata['observed_at'],
|
||||
'permission_verified_at' => $metadata['verified_at'],
|
||||
'permission_evidence_evaluator' => $metadata['evaluator'],
|
||||
'permission_evidence_evaluator_version' => $metadata['evaluator_version'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function currentnessDays(): int
|
||||
{
|
||||
$configured = config('tenantpilot.exchange_powershell.permission_evidence.currentness_days', 30);
|
||||
|
||||
return max(1, (int) $configured);
|
||||
}
|
||||
|
||||
private function normalizedStatus(mixed $status): string
|
||||
{
|
||||
if (! is_string($status) || trim($status) === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$status = strtolower(trim($status));
|
||||
|
||||
return in_array($status, ['granted', 'missing', 'absent', 'revoked', 'blocked', 'unknown', 'error'], true)
|
||||
? $status
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{source: string, observed_at: string, verified_at: string, evaluator: string, evaluator_version: string} $metadata
|
||||
*/
|
||||
private function permissionEvidenceIsStale(ManagedEnvironmentPermission $permission, array $metadata): bool
|
||||
{
|
||||
$freshSince = now()->subDays($this->currentnessDays());
|
||||
$observedAt = $this->safeTimestamp($metadata['observed_at']);
|
||||
$verifiedAt = $this->safeTimestamp($metadata['verified_at']);
|
||||
|
||||
return ! $permission->last_checked_at
|
||||
|| $permission->last_checked_at->lt($freshSince)
|
||||
|| ! $observedAt instanceof Carbon
|
||||
|| ! $verifiedAt instanceof Carbon
|
||||
|| $observedAt->lt($freshSince)
|
||||
|| $verifiedAt->lt($freshSince);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $details
|
||||
* @return array{source: string, observed_at: string, verified_at: string, evaluator: string, evaluator_version: string}|null
|
||||
*/
|
||||
private function validatedEvidenceMetadata(array $details): ?array
|
||||
{
|
||||
$source = $this->safeString($details['source'] ?? null);
|
||||
|
||||
if ($source !== self::TRUSTED_EVIDENCE_SOURCE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $this->safeTimestamp($details['observed_at'] ?? null);
|
||||
$verifiedAt = $this->safeTimestamp($details['verified_at'] ?? null);
|
||||
|
||||
if (! $observedAt instanceof Carbon || ! $verifiedAt instanceof Carbon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($observedAt->isFuture() || $verifiedAt->isFuture() || $verifiedAt->lt($observedAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$evaluator = $this->safeString($details['evaluator'] ?? null);
|
||||
$evaluatorVersion = $this->safeString($details['evaluator_version'] ?? null);
|
||||
|
||||
if ($evaluator !== self::EVIDENCE_EVALUATOR || $evaluatorVersion !== self::EVIDENCE_EVALUATOR_VERSION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'source' => $source,
|
||||
'observed_at' => $observedAt->toJSON(),
|
||||
'verified_at' => $verifiedAt->toJSON(),
|
||||
'evaluator' => $evaluator,
|
||||
'evaluator_version' => $evaluatorVersion,
|
||||
];
|
||||
}
|
||||
|
||||
private function safeString(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/\A[A-Za-z0-9_.:-]{1,80}\z/', $value) === 1 ? $value : null;
|
||||
}
|
||||
|
||||
private function safeTimestamp(mixed $value): ?Carbon
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
|
||||
if (preg_match('/\A(?<year>\d{4})-(?<month>0[1-9]|1[0-2])-(?<day>0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,6})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z/', $value, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! checkdate((int) $matches['month'], (int) $matches['day'], (int) $matches['year'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $details
|
||||
* @return array{workspace_id: int, managed_environment_id: int, provider_connection_id: int}|null
|
||||
*/
|
||||
private function validatedScopeIds(array $details): ?array
|
||||
{
|
||||
$workspaceId = $this->safePositiveInteger($details['workspace_id'] ?? null);
|
||||
$environmentId = $this->safePositiveInteger($details['managed_environment_id'] ?? null);
|
||||
$providerConnectionId = $this->safePositiveInteger($details['provider_connection_id'] ?? null);
|
||||
|
||||
if ($workspaceId === null || $environmentId === null || $providerConnectionId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'workspace_id' => $workspaceId,
|
||||
'managed_environment_id' => $environmentId,
|
||||
'provider_connection_id' => $providerConnectionId,
|
||||
];
|
||||
}
|
||||
|
||||
private function safePositiveInteger(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value === '' || $value !== trim($value) || preg_match('/\A[1-9]\d{0,18}\z/', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$integer = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
|
||||
return is_int($integer) ? $integer : null;
|
||||
}
|
||||
|
||||
private function blocked(
|
||||
string $failureCode,
|
||||
string $stateKey,
|
||||
string $state,
|
||||
?ManagedEnvironmentPermission $permission = null,
|
||||
array $context = [],
|
||||
): ExchangePowerShellGateResult {
|
||||
return ExchangePowerShellGateResult::blocked(
|
||||
reasonCode: ProviderReasonCodes::ProviderPermissionMissing,
|
||||
@ -75,6 +278,8 @@ private function blocked(
|
||||
$stateKey => $state,
|
||||
'permission_key' => self::PERMISSION_KEY,
|
||||
'permission_evidence_id' => $permission instanceof ManagedEnvironmentPermission ? (int) $permission->getKey() : null,
|
||||
'permission_currentness_days' => $this->currentnessDays(),
|
||||
...$context,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,8 +14,7 @@ final class ExchangePowerShellProductionRunner implements ExchangePowerShellComm
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExchangePowerShellRuntimeReadinessChecker $runtimeReadiness,
|
||||
private readonly ExchangePowerShellCredentialReferenceResolver $credentials,
|
||||
private readonly ExchangePowerShellPermissionEvidenceEvaluator $permissions,
|
||||
private readonly ExchangePowerShellInvocationReadinessEvaluator $readiness,
|
||||
private readonly ExchangePowerShellProcessCommandBuilder $commands,
|
||||
private readonly ExchangePowerShellProcessExecutor $executor,
|
||||
private readonly ExchangePowerShellOutputGuard $outputGuard,
|
||||
@ -54,16 +53,10 @@ public function run(
|
||||
return $this->blocked('scope_blocked_provider_connection_mismatch', ['scope_state' => 'mismatch']);
|
||||
}
|
||||
|
||||
$credentialGate = $this->credentials->resolve($connection);
|
||||
$readinessGate = $this->readiness->evaluate($environment, $connection);
|
||||
|
||||
if (! $credentialGate->allowed) {
|
||||
return $this->blockedFromGate($credentialGate);
|
||||
}
|
||||
|
||||
$permissionGate = $this->permissions->evaluate($environment, $connection);
|
||||
|
||||
if (! $permissionGate->allowed) {
|
||||
return $this->blockedFromGate($permissionGate);
|
||||
if (! $readinessGate->allowed) {
|
||||
return $this->blockedFromGate($readinessGate);
|
||||
}
|
||||
|
||||
$runtimePolicy = ExchangePowerShellRuntimePolicy::fromConfig();
|
||||
@ -107,9 +100,10 @@ public function run(
|
||||
return $this->mergeResultContext($result, array_filter([
|
||||
'runner_mode' => ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
'execution_enabled' => true,
|
||||
'readiness_state' => $readinessGate->context['readiness_state'] ?? null,
|
||||
'runtime_state' => $runtimeGate->context['runtime_state'] ?? null,
|
||||
'credential_state' => $credentialGate->context['credential_state'] ?? null,
|
||||
'permission_evidence_state' => $permissionGate->context['permission_evidence_state'] ?? null,
|
||||
'credential_state' => $readinessGate->context['credential_state'] ?? null,
|
||||
'permission_evidence_state' => $readinessGate->context['permission_evidence_state'] ?? null,
|
||||
'command_builder_state' => $build->context['command_builder_state'] ?? null,
|
||||
'concurrency_state' => 'released',
|
||||
'duration_ms' => $result->context['duration_ms'] ?? null,
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellInvocationReadinessEvaluator;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
@ -23,6 +24,7 @@ public function __construct(
|
||||
private readonly ProviderCapabilityRegistry $registry,
|
||||
private readonly ManagedEnvironmentRequiredPermissionsViewModelBuilder $requiredPermissionsViewModelBuilder,
|
||||
private readonly ProviderReadinessResolver $providerReadinessResolver,
|
||||
private readonly ExchangePowerShellInvocationReadinessEvaluator $exchangePowerShellReadiness,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -239,6 +241,10 @@ private function evaluateDefinition(
|
||||
);
|
||||
}
|
||||
|
||||
if ($definition->key === 'exchange_powershell_invoke') {
|
||||
return $this->evaluateExchangePowerShellInvocationReadiness($tenant, $connection, $definition, $base);
|
||||
}
|
||||
|
||||
if ($definition->providerRequirementKeys === ['permissions.admin_consent']) {
|
||||
return new ProviderCapabilityResult(
|
||||
...$base,
|
||||
@ -251,6 +257,65 @@ private function evaluateDefinition(
|
||||
return $this->evaluatePermissionRequirements($tenant, $connection, $definition, $requiredPermissionsViewModel, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $base
|
||||
*/
|
||||
private function evaluateExchangePowerShellInvocationReadiness(
|
||||
ManagedEnvironment $tenant,
|
||||
ProviderConnection $connection,
|
||||
ProviderCapabilityDefinition $definition,
|
||||
array $base,
|
||||
): ProviderCapabilityResult {
|
||||
$readiness = $this->exchangePowerShellReadiness->evaluate($tenant, $connection);
|
||||
|
||||
if ($readiness->allowed) {
|
||||
return new ProviderCapabilityResult(
|
||||
...$base,
|
||||
status: ProviderCapabilityStatus::Supported,
|
||||
primaryMessage: "{$definition->label} capability is supported for this provider connection.",
|
||||
evidenceCounts: ['requirements' => 2, 'missing' => 0, 'errors' => 0],
|
||||
);
|
||||
}
|
||||
|
||||
$status = $this->exchangePowerShellCapabilityStatus($readiness->failureCode);
|
||||
$blocker = is_string($readiness->context['readiness_blocker'] ?? null)
|
||||
? (string) $readiness->context['readiness_blocker']
|
||||
: 'evidence';
|
||||
|
||||
return new ProviderCapabilityResult(
|
||||
...$base,
|
||||
status: $status,
|
||||
reasonCode: $readiness->reasonCode ?? ProviderReasonCodes::ProviderPermissionMissing,
|
||||
missingRequirementKeys: ['provider.exchange_powershell_invocation'],
|
||||
primaryMessage: "{$definition->label} capability requires current scoped credential and permission evidence.",
|
||||
providerHint: $blocker === 'credential'
|
||||
? 'Configure a supported provider credential reference before starting Exchange PowerShell invocation.'
|
||||
: 'Rerun provider verification so Exchange PowerShell permission evidence is current and scoped.',
|
||||
evidenceCounts: [
|
||||
'requirements' => 2,
|
||||
'missing' => $status === ProviderCapabilityStatus::Missing ? 1 : 0,
|
||||
'errors' => $status === ProviderCapabilityStatus::Missing ? 0 : 1,
|
||||
],
|
||||
nextStepLabel: $blocker === 'permission' ? 'Open Required Permissions' : null,
|
||||
nextStepUrl: $blocker === 'permission' ? RequiredPermissionsLinks::requiredPermissions($tenant) : null,
|
||||
);
|
||||
}
|
||||
|
||||
private function exchangePowerShellCapabilityStatus(?string $failureCode): ProviderCapabilityStatus
|
||||
{
|
||||
$failureCode = is_string($failureCode) ? $failureCode : '';
|
||||
|
||||
if (str_contains($failureCode, '_missing') || str_contains($failureCode, '_absent')) {
|
||||
return ProviderCapabilityStatus::Missing;
|
||||
}
|
||||
|
||||
if (str_contains($failureCode, '_stale') || str_contains($failureCode, '_unknown') || str_contains($failureCode, '_unvalidated')) {
|
||||
return ProviderCapabilityStatus::Unknown;
|
||||
}
|
||||
|
||||
return ProviderCapabilityStatus::Blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $requiredPermissionsViewModel
|
||||
* @param array<string, mixed> $base
|
||||
|
||||
@ -606,6 +606,9 @@
|
||||
explode(',', (string) env('TENANTPILOT_EXCHANGE_POWERSHELL_SUPPORTED_REFERENCE_KINDS', '')),
|
||||
))),
|
||||
],
|
||||
'permission_evidence' => [
|
||||
'currentness_days' => (int) env('TENANTPILOT_EXCHANGE_POWERSHELL_PERMISSION_EVIDENCE_CURRENTNESS_DAYS', 30),
|
||||
],
|
||||
'runtime' => [
|
||||
'allowed_environments' => array_values(array_filter(array_map(
|
||||
static fn (string $environment): string => trim($environment),
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\TenantConfigurationResource;
|
||||
use App\Models\TenantConfigurationResourceEvidence;
|
||||
use App\Models\User;
|
||||
@ -22,16 +23,22 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use App\Support\Verification\ManagedEnvironmentPermissionCheckClusters;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
beforeEach(function (): void {
|
||||
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => false]);
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => false,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Spec431 fake runner invocation creates a completed count-only OperationRun before runner execution', function (): void {
|
||||
@ -100,7 +107,7 @@
|
||||
|
||||
it('Spec431 denies in-scope users missing provider-run capability as forbidden without OperationRun', function (): void {
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
||||
$connection = spec431ProviderConnection($environment);
|
||||
$connection = spec431ProviderConnection($environment, seedSupportedCredential: false);
|
||||
$runner = spec431FakeRunner();
|
||||
|
||||
expect(fn () => app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
@ -228,7 +235,7 @@
|
||||
expect($runner->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'provider_capability.provider_capability_key'))->toBe('exchange_powershell_invoke')
|
||||
->and(data_get($run->context, 'provider_capability.status'))->toBe('unknown')
|
||||
->and(data_get($run->context, 'provider_capability.status'))->toBe('missing')
|
||||
->and(data_get($run->context, 'provider_capability.missing_requirement_keys'))->toContain('provider.exchange_powershell_invocation');
|
||||
});
|
||||
|
||||
@ -563,6 +570,7 @@ function spec431ProviderConnection(
|
||||
ManagedEnvironment $environment,
|
||||
array $overrides = [],
|
||||
bool $seedExchangePowerShellPermission = true,
|
||||
bool $seedSupportedCredential = true,
|
||||
): ProviderConnection {
|
||||
$connection = ProviderConnection::factory()
|
||||
->dedicated()
|
||||
@ -581,6 +589,10 @@ function spec431ProviderConnection(
|
||||
spec431SeedExchangePowerShellPermission($environment, $connection);
|
||||
}
|
||||
|
||||
if ($seedSupportedCredential) {
|
||||
spec431Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
@ -595,7 +607,11 @@ function spec431SeedExchangePowerShellPermission(ManagedEnvironment $environment
|
||||
[
|
||||
'status' => $status,
|
||||
'details' => [
|
||||
'source' => 'spec431-test',
|
||||
'source' => 'provider_verification',
|
||||
'observed_at' => now()->toJSON(),
|
||||
'verified_at' => now()->toJSON(),
|
||||
'evaluator' => 'exchange_powershell_permission_evidence',
|
||||
'evaluator_version' => 'v1',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||
'provider' => (string) $connection->provider,
|
||||
@ -606,6 +622,37 @@ function spec431SeedExchangePowerShellPermission(ManagedEnvironment $environment
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function spec431Credential(ProviderConnection $connection, string $kind, array $overrides = []): ProviderCredential
|
||||
{
|
||||
$storedKind = in_array($kind, ProviderCredentialKind::values(), true)
|
||||
? $kind
|
||||
: ProviderCredentialKind::ClientSecret->value;
|
||||
|
||||
$credential = ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'type' => $storedKind,
|
||||
'credential_kind' => $storedKind,
|
||||
'source' => ProviderCredentialSource::DedicatedManual->value,
|
||||
'last_rotated_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
...$overrides,
|
||||
]);
|
||||
|
||||
if ($storedKind !== $kind) {
|
||||
DB::table('provider_credentials')
|
||||
->where('id', (int) $credential->getKey())
|
||||
->update([
|
||||
'type' => $kind,
|
||||
'credential_kind' => $kind,
|
||||
]);
|
||||
}
|
||||
|
||||
return $credential;
|
||||
}
|
||||
|
||||
function spec431FakeRunner(string $scenario = FakeExchangePowerShellCommandRunner::SCENARIO_SUCCESS): FakeExchangePowerShellCommandRunner
|
||||
{
|
||||
$runner = new FakeExchangePowerShellCommandRunner($scenario);
|
||||
|
||||
@ -72,10 +72,12 @@
|
||||
it('Spec432 invocation feature flag blocks production mode even when production runner flag is true', function (): void {
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
$executor = spec432FakeExecutor();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
@ -93,10 +95,14 @@
|
||||
});
|
||||
|
||||
it('Spec432 production runner flag false blocks production mode before process execution', function (): void {
|
||||
config([ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true]);
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
||||
]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec432ProviderConnection($environment);
|
||||
spec432Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
$executor = spec432FakeExecutor();
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
@ -133,9 +139,11 @@
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('credential_blocked_missing_reference')
|
||||
->and(data_get($run->context, 'credential_policy.credential_material_resolved_by_invocation_gate'))->toBeFalse()
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBe('missing');
|
||||
->and(data_get($run->context, 'provider_capability.provider_capability_key'))->toBe('exchange_powershell_invoke')
|
||||
->and(data_get($run->context, 'provider_capability.status'))->toBe('missing')
|
||||
->and(data_get($run->context, 'provider_capability.missing_requirement_keys'))->toContain('provider.exchange_powershell_invocation')
|
||||
->and(data_get($run->context, 'credential_policy.credential_material_resolved_by_invocation_gate'))->toBeNull()
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBeNull();
|
||||
});
|
||||
|
||||
it('Spec432 blocks secret-based app credentials without persisting credential payload or raw output tokens', function (): void {
|
||||
@ -170,8 +178,10 @@
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('credential_blocked_secret_based_app')
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBe('secret_based_app_unsupported')
|
||||
->and(data_get($run->context, 'provider_capability.provider_capability_key'))->toBe('exchange_powershell_invoke')
|
||||
->and(data_get($run->context, 'provider_capability.status'))->toBe('blocked')
|
||||
->and(data_get($run->context, 'provider_capability.reason_code'))->toBe('provider_credential_invalid')
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBeNull()
|
||||
->and($persisted)
|
||||
->not->toContain('spec432-secret-material')
|
||||
->not->toContain('client_secret')
|
||||
@ -225,8 +235,10 @@
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value)
|
||||
->and(data_get($run->context, 'failure_code'))->toBe('credential_blocked_certificate_unsupported')
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBe('certificate_unsupported');
|
||||
->and(data_get($run->context, 'provider_capability.provider_capability_key'))->toBe('exchange_powershell_invoke')
|
||||
->and(data_get($run->context, 'provider_capability.status'))->toBe('blocked')
|
||||
->and(data_get($run->context, 'provider_capability.reason_code'))->toBe('provider_credential_invalid')
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBeNull();
|
||||
});
|
||||
|
||||
it('Spec432 credential reference resolver covers fail-closed credential states without payload exposure', function (
|
||||
@ -317,7 +329,11 @@
|
||||
'permission_key' => 'Exchange.ManageAsApp',
|
||||
'status' => 'granted',
|
||||
'details' => [
|
||||
'source' => 'spec432-test',
|
||||
'source' => 'provider_verification',
|
||||
'observed_at' => now()->toJSON(),
|
||||
'verified_at' => now()->toJSON(),
|
||||
'evaluator' => 'exchange_powershell_permission_evidence',
|
||||
'evaluator_version' => 'v1',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||
'provider' => (string) $connection->provider,
|
||||
@ -337,8 +353,8 @@
|
||||
'unvalidated' => [
|
||||
[],
|
||||
['status' => 'missing'],
|
||||
'permission_evidence_blocked_unvalidated',
|
||||
'unvalidated',
|
||||
'permission_evidence_blocked_absent',
|
||||
'absent',
|
||||
],
|
||||
'stale' => [
|
||||
[],
|
||||
@ -684,7 +700,11 @@ function spec432SeedExchangePowerShellPermission(ManagedEnvironment $environment
|
||||
[
|
||||
'status' => $status,
|
||||
'details' => [
|
||||
'source' => 'spec432-test',
|
||||
'source' => 'provider_verification',
|
||||
'observed_at' => now()->toJSON(),
|
||||
'verified_at' => now()->toJSON(),
|
||||
'evaluator' => 'exchange_powershell_permission_evidence',
|
||||
'evaluator_version' => 'v1',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||
'provider' => (string) $connection->provider,
|
||||
|
||||
@ -0,0 +1,543 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentPermission;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\TenantConfigurationResource;
|
||||
use App\Models\TenantConfigurationResourceEvidence;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellCredentialReferenceResolver;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellInvocationContext;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellInvocationReadinessEvaluator;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellPermissionEvidenceEvaluator;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessExecutor;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProcessResult;
|
||||
use App\Services\TenantConfiguration\ExchangePowerShellProductionRunner;
|
||||
use App\Services\TenantConfiguration\FakeExchangePowerShellProcessExecutor;
|
||||
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator;
|
||||
use App\Support\Providers\Capabilities\ProviderCapabilityStatus;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderCredentialKind;
|
||||
use App\Support\Providers\ProviderCredentialSource;
|
||||
use App\Support\Providers\ProviderVerificationStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function (): void {
|
||||
app(ResourceTypeRegistry::class)->syncDefaults();
|
||||
|
||||
config([
|
||||
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
|
||||
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => [],
|
||||
'tenantpilot.exchange_powershell.permission_evidence.currentness_days' => 30,
|
||||
'tenantpilot.exchange_powershell.runtime.allowed_environments' => [app()->environment()],
|
||||
'tenantpilot.exchange_powershell.runtime.module_check_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('Spec433 uses existing provider credential and managed environment permission metadata without schema changes', function (): void {
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment);
|
||||
spec433Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection);
|
||||
|
||||
config(['tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate']]);
|
||||
|
||||
$result = app(ExchangePowerShellInvocationReadinessEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
expect($result->allowed)->toBeTrue()
|
||||
->and($result->context['readiness_state'])->toBe('ready_for_live_invocation')
|
||||
->and($result->context['credential_state'])->toBe('certificate_supported')
|
||||
->and($result->context['permission_evidence_state'])->toBe('verified')
|
||||
->and(Schema::hasColumn('provider_credentials', 'provider_connection_id'))->toBeTrue()
|
||||
->and(Schema::hasColumn('provider_credentials', 'credential_kind'))->toBeTrue()
|
||||
->and(Schema::hasColumn('provider_credentials', 'source'))->toBeTrue()
|
||||
->and(Schema::hasColumn('provider_credentials', 'last_rotated_at'))->toBeTrue()
|
||||
->and(Schema::hasColumn('provider_credentials', 'expires_at'))->toBeTrue()
|
||||
->and(Schema::hasColumn('managed_environment_permissions', 'workspace_id'))->toBeTrue()
|
||||
->and(Schema::hasColumn('managed_environment_permissions', 'details'))->toBeTrue()
|
||||
->and(glob(database_path('migrations/*spec433*')) ?: [])->toBe([])
|
||||
->and(glob(database_path('migrations/*exchange*credential*')) ?: [])->toBe([]);
|
||||
});
|
||||
|
||||
it('Spec433 credential readiness blocks unsafe or unobservable states without leaking material', function (
|
||||
?string $credentialKind,
|
||||
array $overrides,
|
||||
string $expectedFailureCode,
|
||||
string $expectedState,
|
||||
): void {
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
|
||||
if ($credentialKind !== null) {
|
||||
spec433Credential($connection, $credentialKind, [
|
||||
'payload' => [
|
||||
'private_key' => 'spec433-private-key',
|
||||
'certificate_password' => 'spec433-certificate-password',
|
||||
'client_secret' => 'spec433-client-secret',
|
||||
'access_token' => 'spec433-access-token',
|
||||
],
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(ExchangePowerShellCredentialReferenceResolver::class)->resolve($connection);
|
||||
$payload = json_encode($result, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($result->allowed)->toBeFalse()
|
||||
->and($result->failureCode)->toBe($expectedFailureCode)
|
||||
->and($result->context['credential_state'])->toBe($expectedState)
|
||||
->and($payload)
|
||||
->not->toContain('spec433-private-key')
|
||||
->not->toContain('spec433-certificate-password')
|
||||
->not->toContain('spec433-client-secret')
|
||||
->not->toContain('spec433-access-token')
|
||||
->not->toContain('client_secret');
|
||||
})->with([
|
||||
'missing credential' => [null, [], 'credential_blocked_missing_reference', 'missing'],
|
||||
'client secret' => [ProviderCredentialKind::ClientSecret->value, [], 'credential_blocked_secret_based_app', 'secret_based_app_unsupported'],
|
||||
'certificate missing expiry metadata' => [ProviderCredentialKind::Certificate->value, ['expires_at' => null], 'credential_blocked_certificate_missing', 'certificate_missing'],
|
||||
'certificate expired' => [ProviderCredentialKind::Certificate->value, ['expires_at' => now()->subDay()], 'credential_blocked_certificate_expired', 'certificate_expired'],
|
||||
'certificate unsupported' => [ProviderCredentialKind::Certificate->value, [], 'credential_blocked_certificate_unsupported', 'certificate_unsupported'],
|
||||
'federated unsupported' => [ProviderCredentialKind::Federated->value, [], 'credential_blocked_federated_unsupported', 'federated_unsupported'],
|
||||
'managed identity unsupported' => ['managed_identity', [], 'credential_blocked_managed_identity_unsupported', 'managed_identity_unsupported'],
|
||||
'unknown kind' => ['unknown_kind', [], 'credential_blocked_unknown_kind', 'unknown_kind'],
|
||||
]);
|
||||
|
||||
it('Spec433 permission evidence enforces source currentness and provider connection scope', function (
|
||||
array $detailsOverrides,
|
||||
array $rowOverrides,
|
||||
string $expectedFailureCode,
|
||||
string $expectedState,
|
||||
): void {
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection, [
|
||||
'details' => [
|
||||
'source' => 'provider_verification',
|
||||
'observed_at' => now()->toJSON(),
|
||||
'verified_at' => now()->toJSON(),
|
||||
'evaluator' => 'exchange_powershell_permission_evidence',
|
||||
'evaluator_version' => 'v1',
|
||||
...$detailsOverrides,
|
||||
],
|
||||
...$rowOverrides,
|
||||
]);
|
||||
|
||||
$result = app(ExchangePowerShellPermissionEvidenceEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
expect($result->allowed)->toBeFalse()
|
||||
->and($result->failureCode)->toBe($expectedFailureCode)
|
||||
->and($result->context['permission_evidence_state'])->toBe($expectedState);
|
||||
})->with([
|
||||
'absent' => [[], ['status' => 'missing'], 'permission_evidence_blocked_absent', 'absent'],
|
||||
'unknown' => [[], ['status' => 'error'], 'permission_evidence_blocked_unknown', 'unknown'],
|
||||
'source missing' => [['source' => null], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'source untrusted' => [['source' => 'manual_note'], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'source malformed' => [['source' => 'provider verification raw payload'], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'observed timestamp missing' => [['observed_at' => null], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'verified timestamp missing' => [['verified_at' => null], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'observed timestamp relative string' => [['observed_at' => 'now'], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'verified timestamp relative string' => [['verified_at' => 'now'], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'observed timestamp invalid calendar date' => [['observed_at' => '2026-02-30T10:00:00Z'], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'verified timestamp invalid offset' => [['verified_at' => '2026-07-07T22:41:10+24:00'], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'verified before observed' => [['observed_at' => now()->toJSON(), 'verified_at' => now()->subMinute()->toJSON()], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'evaluator missing' => [['evaluator' => null], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'evaluator version missing' => [['evaluator_version' => null], [], 'permission_evidence_blocked_unvalidated', 'unvalidated'],
|
||||
'observed timestamp stale' => [['observed_at' => now()->subDays(31)->toJSON(), 'verified_at' => now()->toJSON()], [], 'permission_evidence_blocked_stale', 'stale'],
|
||||
'verified timestamp stale' => [['observed_at' => now()->subDays(31)->subMinute()->toJSON(), 'verified_at' => now()->subDays(31)->toJSON()], [], 'permission_evidence_blocked_stale', 'stale'],
|
||||
'stale by configurable threshold' => [[], ['last_checked_at' => now()->subDays(31)], 'permission_evidence_blocked_stale', 'stale'],
|
||||
'wrong workspace' => [['workspace_id' => 999999], [], 'permission_evidence_blocked_wrong_workspace', 'wrong_workspace'],
|
||||
'wrong environment' => [['managed_environment_id' => 999999], [], 'permission_evidence_blocked_wrong_environment', 'wrong_environment'],
|
||||
'unsupported provider' => [['provider' => 'other'], [], 'permission_evidence_blocked_unsupported_provider', 'unsupported_provider'],
|
||||
'wrong provider connection' => [['provider_connection_id' => 999999], [], 'permission_evidence_blocked_wrong_provider_connection', 'wrong_provider_connection'],
|
||||
]);
|
||||
|
||||
it('Spec433 rejects malformed permission evidence scope identifiers before scope matching', function (
|
||||
string $scopeKey,
|
||||
string $malformedSuffix,
|
||||
): void {
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
$expectedValue = match ($scopeKey) {
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
default => throw new InvalidArgumentException('Unsupported scope key for Spec433 test.'),
|
||||
};
|
||||
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection, [
|
||||
'details' => [
|
||||
$scopeKey => ((string) $expectedValue).$malformedSuffix,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = app(ExchangePowerShellPermissionEvidenceEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
expect($result->allowed)->toBeFalse()
|
||||
->and($result->failureCode)->toBe('permission_evidence_blocked_unvalidated')
|
||||
->and($result->context['permission_evidence_state'])->toBe('unvalidated');
|
||||
})->with([
|
||||
'workspace id numeric prefix' => fn (): array => [
|
||||
'workspace_id',
|
||||
'abc',
|
||||
],
|
||||
'managed environment id decimal' => fn (): array => [
|
||||
'managed_environment_id',
|
||||
'.0',
|
||||
],
|
||||
'provider connection id scientific notation' => fn (): array => [
|
||||
'provider_connection_id',
|
||||
'e0',
|
||||
],
|
||||
'provider connection id whitespace padded' => fn (): array => [
|
||||
'provider_connection_id',
|
||||
' ',
|
||||
],
|
||||
]);
|
||||
|
||||
it('Spec433 honors configured permission evidence currentness without fallback-to-latest behavior', function (): void {
|
||||
config(['tenantpilot.exchange_powershell.permission_evidence.currentness_days' => 10]);
|
||||
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection, [
|
||||
'last_checked_at' => now()->subDays(11),
|
||||
]);
|
||||
|
||||
$result = app(ExchangePowerShellPermissionEvidenceEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
expect($result->allowed)->toBeFalse()
|
||||
->and($result->failureCode)->toBe('permission_evidence_blocked_stale')
|
||||
->and($result->context['permission_currentness_days'])->toBe(10)
|
||||
->and(ManagedEnvironmentPermission::query()
|
||||
->where('managed_environment_id', (int) $environment->getKey())
|
||||
->where('permission_key', 'Exchange.ManageAsApp')
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('Spec433 combined readiness requires both credential and permission evidence and does not promote evidence', function (): void {
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
spec433Credential($connection, ProviderCredentialKind::ClientSecret->value);
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection);
|
||||
|
||||
$secretCredentialResult = app(ExchangePowerShellInvocationReadinessEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
$connection->credential()->delete();
|
||||
config(['tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate']]);
|
||||
spec433Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection, [
|
||||
'last_checked_at' => now()->subDays(31),
|
||||
]);
|
||||
|
||||
$stalePermissionResult = app(ExchangePowerShellInvocationReadinessEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection);
|
||||
$readyResult = app(ExchangePowerShellInvocationReadinessEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
expect($secretCredentialResult->allowed)->toBeFalse()
|
||||
->and($secretCredentialResult->context['readiness_blocker'])->toBe('credential')
|
||||
->and($stalePermissionResult->allowed)->toBeFalse()
|
||||
->and($stalePermissionResult->context['readiness_blocker'])->toBe('permission')
|
||||
->and($readyResult->allowed)->toBeTrue()
|
||||
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
||||
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('Spec433 provider capability is supported only when combined readiness passes', function (
|
||||
string $case,
|
||||
array $connectionOverrides,
|
||||
?string $credentialKind,
|
||||
array $permissionOverrides,
|
||||
?ProviderCapabilityStatus $expectedStatus,
|
||||
): void {
|
||||
config(['tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate']]);
|
||||
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment, $connectionOverrides, seedExchangePowerShellPermission: false);
|
||||
|
||||
if ($credentialKind !== null) {
|
||||
spec433Credential($connection, $credentialKind);
|
||||
}
|
||||
|
||||
if ($permissionOverrides !== ['missing' => true]) {
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection, $permissionOverrides);
|
||||
}
|
||||
|
||||
$result = app(ProviderCapabilityEvaluator::class)->evaluate($environment, $connection, 'exchange_powershell_invoke');
|
||||
|
||||
expect($result->status)->toBe($expectedStatus)
|
||||
->and($result->status)->not->toBe(
|
||||
$case === 'ready' ? ProviderCapabilityStatus::Missing : ProviderCapabilityStatus::Supported,
|
||||
);
|
||||
})->with([
|
||||
'admin consent missing' => ['admin_consent_missing', ['consent_status' => ProviderConsentStatus::Required->value, 'consent_granted_at' => null], ProviderCredentialKind::Certificate->value, [], ProviderCapabilityStatus::Missing],
|
||||
'client secret' => ['client_secret', [], ProviderCredentialKind::ClientSecret->value, [], ProviderCapabilityStatus::Blocked],
|
||||
'missing credential' => ['missing_credential', [], null, [], ProviderCapabilityStatus::Missing],
|
||||
'missing permission' => ['missing_permission', [], ProviderCredentialKind::Certificate->value, ['missing' => true], ProviderCapabilityStatus::Missing],
|
||||
'stale permission' => ['stale_permission', [], ProviderCredentialKind::Certificate->value, ['last_checked_at' => now()->subDays(31)], ProviderCapabilityStatus::Unknown],
|
||||
'wrong scope permission' => ['wrong_scope_permission', [], ProviderCredentialKind::Certificate->value, ['details' => ['provider_connection_id' => 999999]], ProviderCapabilityStatus::Blocked],
|
||||
'unsupported provider' => ['unsupported_provider', ['provider' => 'other'], ProviderCredentialKind::Certificate->value, ['details' => ['provider' => 'other']], ProviderCapabilityStatus::NotApplicable],
|
||||
'ready' => ['ready', [], ProviderCredentialKind::Certificate->value, [], ProviderCapabilityStatus::Supported],
|
||||
]);
|
||||
|
||||
it('Spec433 provider capability uses configured Exchange evidence currentness as the shared readiness truth', function (): void {
|
||||
config([
|
||||
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
|
||||
'tenantpilot.exchange_powershell.permission_evidence.currentness_days' => 45,
|
||||
]);
|
||||
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
spec433Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection, [
|
||||
'details' => [
|
||||
'observed_at' => now()->subDays(31)->toJSON(),
|
||||
'verified_at' => now()->subDays(31)->toJSON(),
|
||||
],
|
||||
'last_checked_at' => now()->subDays(31),
|
||||
]);
|
||||
|
||||
$capability = app(ProviderCapabilityEvaluator::class)->evaluate($environment, $connection, 'exchange_powershell_invoke');
|
||||
$readiness = app(ExchangePowerShellInvocationReadinessEvaluator::class)->evaluate($environment, $connection);
|
||||
|
||||
expect($readiness->allowed)->toBeTrue()
|
||||
->and($readiness->context['permission_currentness_days'])->toBe(45)
|
||||
->and($capability->status)->toBe(ProviderCapabilityStatus::Supported);
|
||||
});
|
||||
|
||||
it('Spec433 production runner consumes combined readiness before runtime or process execution', function (): void {
|
||||
config(['tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate']]);
|
||||
|
||||
[, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment, seedExchangePowerShellPermission: false);
|
||||
spec433Credential($connection, ProviderCredentialKind::Certificate->value);
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection, [
|
||||
'last_checked_at' => now()->subDays(31),
|
||||
]);
|
||||
$executor = spec433FakeExecutor();
|
||||
|
||||
$result = app(ExchangePowerShellProductionRunner::class)->run(
|
||||
spec433VerifiedContract('transportRule'),
|
||||
new ExchangePowerShellInvocationContext(
|
||||
operationRunId: 433,
|
||||
workspaceId: (int) $environment->workspace_id,
|
||||
managedEnvironmentId: (int) $environment->getKey(),
|
||||
providerConnectionId: (int) $connection->getKey(),
|
||||
canonicalType: 'transportRule',
|
||||
commandName: 'Get-TransportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
redactionPolicy: ExchangePowerShellInvocationGate::REDACTION_POLICY,
|
||||
sourceContractState: 'contract_verified_pending_capture',
|
||||
),
|
||||
);
|
||||
|
||||
expect($executor->calls)->toBe([])
|
||||
->and($result->blocked)->toBeTrue()
|
||||
->and($result->failureCode)->toBe('permission_evidence_blocked_stale')
|
||||
->and($result->context['readiness_state'])->toBe('blocked')
|
||||
->and($result->context['readiness_blocker'])->toBe('permission');
|
||||
});
|
||||
|
||||
it('Spec433 invocation context persists only safe readiness states and no evidence rows after readiness pass', function (): void {
|
||||
config(['tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate']]);
|
||||
|
||||
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
|
||||
$connection = spec433ProviderConnection($environment);
|
||||
spec433Credential($connection, ProviderCredentialKind::Certificate->value, [
|
||||
'payload' => [
|
||||
'private_key' => 'spec433-private-key',
|
||||
'certificate_password' => 'spec433-certificate-password',
|
||||
],
|
||||
]);
|
||||
spec433FakeExecutor([
|
||||
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"),
|
||||
ExchangePowerShellProcessResult::completed(0, '[{"id":"rule-1","secret":"raw-output-secret"}]', durationMs: 14),
|
||||
]);
|
||||
|
||||
$run = app(ExchangePowerShellInvocationGate::class)->invoke(
|
||||
tenant: $environment,
|
||||
providerConnection: $connection,
|
||||
actor: $user,
|
||||
canonicalType: 'transportRule',
|
||||
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
|
||||
);
|
||||
|
||||
$persisted = json_encode([
|
||||
'context' => $run->context,
|
||||
'failure_summary' => $run->failure_summary,
|
||||
'summary_counts' => $run->summary_counts,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and(data_get($run->context, 'runner_result.readiness_state'))->toBe('ready_for_live_invocation')
|
||||
->and(data_get($run->context, 'runner_result.credential_state'))->toBe('certificate_supported')
|
||||
->and(data_get($run->context, 'runner_result.permission_evidence_state'))->toBe('verified')
|
||||
->and(TenantConfigurationResource::query()->count())->toBe(0)
|
||||
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0)
|
||||
->and($persisted)
|
||||
->not->toContain('raw-output-secret')
|
||||
->not->toContain('spec433-private-key')
|
||||
->not->toContain('spec433-certificate-password')
|
||||
->not->toContain('raw_shell_stdout')
|
||||
->not->toContain('raw_shell_stderr')
|
||||
->not->toContain('client_secret')
|
||||
->not->toContain('access_token');
|
||||
});
|
||||
|
||||
it('Spec433 adds no UI route trigger migration or tenant-id ownership surface', function (): void {
|
||||
$runtimeFiles = [
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellCredentialReferenceResolver.php'),
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellPermissionEvidenceEvaluator.php'),
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellInvocationReadinessEvaluator.php'),
|
||||
app_path('Services/TenantConfiguration/ExchangePowerShellProductionRunner.php'),
|
||||
app_path('Support/Providers/Capabilities/ProviderCapabilityEvaluator.php'),
|
||||
];
|
||||
$runtimeSource = collect($runtimeFiles)
|
||||
->map(fn (string $file): string => file_get_contents($file) ?: '')
|
||||
->implode("\n");
|
||||
|
||||
expect(preg_match('/\btenant_id\b/', $runtimeSource))->toBe(0);
|
||||
|
||||
expect($runtimeSource)
|
||||
->not->toContain('Start-')
|
||||
->not->toContain('Set-')
|
||||
->not->toContain('New-')
|
||||
->not->toContain('Remove-')
|
||||
->and(glob(app_path('Filament/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
||||
->and(glob(base_path('routes/*exchange*')) ?: [])->toBe([])
|
||||
->and(glob(resource_path('views/**/*ExchangePowerShell*')) ?: [])->toBe([])
|
||||
->and(glob(database_path('migrations/*exchange*')) ?: [])->toBe([])
|
||||
->and(File::exists(app_path('Jobs/TenantConfiguration/InvokeExchangePowerShell.php')))->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function spec433ProviderConnection(
|
||||
ManagedEnvironment $environment,
|
||||
array $overrides = [],
|
||||
bool $seedExchangePowerShellPermission = true,
|
||||
): ProviderConnection {
|
||||
$connection = ProviderConnection::factory()
|
||||
->dedicated()
|
||||
->consentGranted()
|
||||
->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'entra_tenant_id' => $environment->providerTenantContext(),
|
||||
'is_default' => true,
|
||||
'verification_status' => ProviderVerificationStatus::Healthy->value,
|
||||
'last_health_check_at' => now(),
|
||||
...$overrides,
|
||||
]);
|
||||
|
||||
if ($seedExchangePowerShellPermission) {
|
||||
spec433SeedExchangePowerShellPermission($environment, $connection);
|
||||
}
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function spec433SeedExchangePowerShellPermission(ManagedEnvironment $environment, ProviderConnection $connection, array $overrides = []): ManagedEnvironmentPermission
|
||||
{
|
||||
$details = [
|
||||
'source' => 'provider_verification',
|
||||
'observed_at' => now()->toJSON(),
|
||||
'verified_at' => now()->toJSON(),
|
||||
'evaluator' => 'exchange_powershell_permission_evidence',
|
||||
'evaluator_version' => 'v1',
|
||||
'workspace_id' => (int) $connection->workspace_id,
|
||||
'managed_environment_id' => (int) $connection->managed_environment_id,
|
||||
'provider' => (string) $connection->provider,
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
];
|
||||
|
||||
if (is_array($overrides['details'] ?? null)) {
|
||||
$details = array_replace($details, $overrides['details']);
|
||||
}
|
||||
|
||||
unset($overrides['details']);
|
||||
|
||||
return ManagedEnvironmentPermission::query()->updateOrCreate(
|
||||
[
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'permission_key' => 'Exchange.ManageAsApp',
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
],
|
||||
[
|
||||
'status' => 'granted',
|
||||
'details' => $details,
|
||||
'last_checked_at' => now(),
|
||||
...$overrides,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function spec433Credential(ProviderConnection $connection, string $kind, array $overrides = []): ProviderCredential
|
||||
{
|
||||
$storedKind = in_array($kind, ProviderCredentialKind::values(), true)
|
||||
? $kind
|
||||
: ProviderCredentialKind::ClientSecret->value;
|
||||
|
||||
$credential = ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'type' => $storedKind,
|
||||
'credential_kind' => $storedKind,
|
||||
'source' => ProviderCredentialSource::DedicatedManual->value,
|
||||
'last_rotated_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
...$overrides,
|
||||
]);
|
||||
|
||||
if ($storedKind !== $kind) {
|
||||
DB::table('provider_credentials')
|
||||
->where('id', (int) $credential->getKey())
|
||||
->update([
|
||||
'type' => $kind,
|
||||
'credential_kind' => $kind,
|
||||
]);
|
||||
}
|
||||
|
||||
return $credential;
|
||||
}
|
||||
|
||||
function spec433FakeExecutor(array $results = []): FakeExchangePowerShellProcessExecutor
|
||||
{
|
||||
$executor = new FakeExchangePowerShellProcessExecutor(results: $results);
|
||||
app()->instance(ExchangePowerShellProcessExecutor::class, $executor);
|
||||
|
||||
return $executor;
|
||||
}
|
||||
|
||||
function spec433VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
|
||||
{
|
||||
$contracts = new ExchangePowerShellCommandContracts;
|
||||
$contract = $contracts->contractForCanonicalType($canonicalType);
|
||||
|
||||
if (! is_array($contract)) {
|
||||
throw new RuntimeException('Missing Spec433 command contract.');
|
||||
}
|
||||
|
||||
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
|
||||
}
|
||||
@ -0,0 +1,229 @@
|
||||
# Requirements Checklist: Exchange Credential and Permission Evidence Support
|
||||
|
||||
**Purpose**: Preparation and implementation-readiness checklist for Spec 433.
|
||||
**Created**: 2026-07-07
|
||||
**Feature**: `specs/433-exchange-credential-permission-evidence-support/spec.md`
|
||||
|
||||
This checklist is a preparation gate now and an implementation close-out checklist later. Completed Specs 429-432 are read-only context and must not be rewritten to satisfy this checklist.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- [x] Selected candidate is directly provided by the user.
|
||||
- [x] `docs/product/spec-candidates.md` auto-prep queue was checked and is empty/manual-only.
|
||||
- [x] Roadmap relationship to Specs 429-434 is recorded.
|
||||
- [x] No existing `specs/433-*` package was present before preparation.
|
||||
- [x] Specs 429-432 completed-spec guardrail result is recorded.
|
||||
- [x] Smallest viable slice is backend credential/permission evidence readiness only.
|
||||
- [x] Close alternatives are deferred as follow-up specs.
|
||||
- [x] Candidate Selection Gate result is PASS.
|
||||
|
||||
## Spec Package
|
||||
|
||||
- [x] `spec.md` exists.
|
||||
- [x] `plan.md` exists.
|
||||
- [x] `tasks.md` exists.
|
||||
- [x] `checklists/requirements.md` exists.
|
||||
- [x] `implementation-report.md` is created by the later implementation loop.
|
||||
- [x] Proportionality review exists in spec and plan.
|
||||
- [x] Product Surface impact for existing provider capability/readiness surfaces is recorded.
|
||||
- [x] Focused browser proof is planned for existing provider capability/readiness surfaces.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [x] Spec 432 implementation report records PASS.
|
||||
- [x] Spec 432 runner boundary exists.
|
||||
- [x] Spec 432 credential resolver exists.
|
||||
- [x] Spec 432 permission evaluator exists.
|
||||
- [x] Spec 432 no-evidence/no-UI/no-migration/no-tenant-id proof is recorded.
|
||||
- [x] Implementation confirms Spec 432 has no open merge-blocking runtime safety finding at start.
|
||||
- [x] Implementation confirms dirty state is safe before code changes.
|
||||
|
||||
## Scope
|
||||
|
||||
- [x] Credential evidence support only.
|
||||
- [x] Permission evidence support only.
|
||||
- [x] Combined readiness support only.
|
||||
- [x] No Exchange provider calls.
|
||||
- [x] No PowerShell execution.
|
||||
- [x] No tenant configuration evidence.
|
||||
- [x] No content-backed evidence.
|
||||
- [x] No compare/render.
|
||||
- [x] No certification.
|
||||
- [x] No restore.
|
||||
- [x] No customer claim.
|
||||
- [x] No new UI/routes/jobs/schedules/listeners.
|
||||
- [x] No `tenant_id` ownership truth.
|
||||
|
||||
## Data Model
|
||||
|
||||
- [x] Existing `ProviderCredential` metadata is proven sufficient, or a provider-agnostic migration is justified.
|
||||
- [x] Existing `ManagedEnvironmentPermission` fields/details are proven sufficient, or a provider-agnostic migration is justified.
|
||||
- [x] Current `managed_environment_id` + `permission_key` uniqueness and provider-connection scope through `details` are addressed.
|
||||
- [x] If migration is required, `spec.md` and `plan.md` are updated before schema code is added. N/A - no migration was needed.
|
||||
- [x] Migration, if present, is provider-agnostic. N/A - no migration was added.
|
||||
- [x] No Exchange-specific credential table.
|
||||
- [x] No Exchange-specific permission table.
|
||||
- [x] No secret material columns.
|
||||
- [x] No private key columns.
|
||||
- [x] No certificate password columns.
|
||||
- [x] No client secret columns.
|
||||
- [x] No token columns.
|
||||
- [x] No raw provider payload columns.
|
||||
- [x] No UI state columns.
|
||||
- [x] No `tenant_id` ownership column.
|
||||
|
||||
## Credential Evidence
|
||||
|
||||
- [x] `client_secret` credentials block.
|
||||
- [x] Missing credential blocks.
|
||||
- [x] Expired credential blocks.
|
||||
- [x] Inaccessible credential blocks distinctly from missing/expired when repo-observable, or collapses to a tested safe blocker.
|
||||
- [x] Unsupported credential blocks.
|
||||
- [x] Wrong-scope credential blocks where scope can be evaluated.
|
||||
- [x] Unvalidated credential blocks where validation metadata is required.
|
||||
- [x] Unknown credential kind blocks.
|
||||
- [x] Certificate is supported safely or explicitly blocked.
|
||||
- [x] Federated credential is supported safely or explicitly blocked.
|
||||
- [x] Managed identity is supported safely or explicitly blocked.
|
||||
- [x] Credential material never reaches OperationRun context, logs, returned envelopes, tests, or persisted metadata.
|
||||
|
||||
## Permission Evidence
|
||||
|
||||
- [x] `Exchange.ManageAsApp` or repo-canonical equivalent is represented.
|
||||
- [x] Admin consent alone is insufficient.
|
||||
- [x] Missing evidence blocks.
|
||||
- [x] Unvalidated evidence blocks.
|
||||
- [x] Stale evidence blocks.
|
||||
- [x] Wrong workspace evidence blocks.
|
||||
- [x] Wrong managed-environment evidence blocks.
|
||||
- [x] Wrong provider-connection evidence blocks.
|
||||
- [x] Unsupported provider evidence blocks.
|
||||
- [x] Revoked or absent evidence blocks.
|
||||
- [x] Currentness threshold is configurable or the fixed threshold is justified and tested.
|
||||
- [x] Source metadata is represented or proven safely in existing details.
|
||||
- [x] Observed/captured/verified metadata is represented or proven safely in existing details.
|
||||
- [x] Current scoped verified evidence passes permission gate.
|
||||
|
||||
## Combined Readiness
|
||||
|
||||
- [x] Credential evidence is required.
|
||||
- [x] Permission evidence is required.
|
||||
- [x] Currentness is required.
|
||||
- [x] Correct workspace, managed-environment, and provider-connection scope is required.
|
||||
- [x] Provider support is required.
|
||||
- [x] Safe blocker states are returned.
|
||||
- [x] `ready_for_live_invocation` or repo-canonical pass state does not create evidence.
|
||||
- [x] Readiness pass does not imply customer-ready/product-ready state.
|
||||
- [x] Provider capability and runner gate consume the same readiness truth or repo-canonical equivalent.
|
||||
|
||||
## Provider Capability
|
||||
|
||||
- [x] `exchange_powershell_invoke` is Supported only when combined readiness passes.
|
||||
- [x] Admin consent alone is not Supported.
|
||||
- [x] `client_secret` alone is not Supported.
|
||||
- [x] Missing credential evidence is not Supported.
|
||||
- [x] Missing permission evidence is not Supported.
|
||||
- [x] Stale permission evidence is not Supported.
|
||||
- [x] Wrong-scope permission evidence is not Supported.
|
||||
- [x] Capability status values remain repo-canonical or any new value has proportionality review.
|
||||
|
||||
## Runner Gate Integration
|
||||
|
||||
- [x] Spec 432 production runner gate consumes combined readiness before process execution.
|
||||
- [x] Disabled default and production-runner config gates remain intact.
|
||||
- [x] OperationRun context stores only safe readiness/blocker state.
|
||||
- [x] No credential material in OperationRun context.
|
||||
- [x] No raw permission payload in OperationRun context.
|
||||
- [x] No raw provider payload in OperationRun context.
|
||||
- [x] No new OperationRun type unless justified in spec/plan before implementation continues.
|
||||
|
||||
## Redaction
|
||||
|
||||
- [x] No private key.
|
||||
- [x] No certificate password.
|
||||
- [x] No client secret.
|
||||
- [x] No token.
|
||||
- [x] No auth header.
|
||||
- [x] No raw vault secret.
|
||||
- [x] No raw provider permission response.
|
||||
- [x] Sensitive metadata is redacted according to repo policy.
|
||||
- [x] Secret-like exception messages are sanitized.
|
||||
|
||||
## No Promotion
|
||||
|
||||
- [x] No `TenantConfigurationResourceEvidence` rows.
|
||||
- [x] No raw Exchange payload.
|
||||
- [x] No normalized Exchange payload.
|
||||
- [x] No content-backed state.
|
||||
- [x] No comparable state.
|
||||
- [x] No renderable state.
|
||||
- [x] No certified state.
|
||||
- [x] No restore-ready state.
|
||||
- [x] No customer-ready state.
|
||||
- [x] No report output.
|
||||
- [x] No Review Pack/PDF output.
|
||||
|
||||
## Product Surface / Trigger
|
||||
|
||||
- [x] Product Surface impact is limited to existing provider capability/readiness surfaces.
|
||||
- [x] Focused browser proof plan covers existing provider capability/readiness surfaces.
|
||||
- [x] Existing provider capability/readiness surfaces stay canonical, redacted, and free of raw credential/permission/provider/OperationRun payloads by default.
|
||||
- [x] No routes added or changed.
|
||||
- [x] No new Filament pages/resources/widgets/actions added or changed.
|
||||
- [x] No Livewire components added or changed.
|
||||
- [x] No navigation added or changed.
|
||||
- [x] No asset changes.
|
||||
- [x] No global search changes.
|
||||
- [x] No credential management UI.
|
||||
- [x] No permission management UI.
|
||||
- [x] No job trigger.
|
||||
- [x] No schedule trigger.
|
||||
- [x] No listener trigger.
|
||||
- [x] No console trigger for Exchange PowerShell.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [x] Uses provider credential/reference architecture.
|
||||
- [x] Uses `ManagedEnvironmentPermission` or repo-canonical permission evidence architecture.
|
||||
- [x] Uses provider capability evaluator.
|
||||
- [x] Uses Spec 432 runner gate.
|
||||
- [x] No `tenant_id` ownership truth.
|
||||
- [x] No Exchange mini-platform.
|
||||
- [x] No legacy shim.
|
||||
- [x] No fallback reader.
|
||||
- [x] No dual write.
|
||||
- [x] No generic provider framework introduced from a single Exchange use case.
|
||||
|
||||
## Validation
|
||||
|
||||
- [x] Focused Spec 433 unit tests pass.
|
||||
- [x] Focused Spec 433 feature tests pass.
|
||||
- [x] Migration/PostgreSQL tests pass if migration exists. N/A - no migration was added; Sail migration check reported nothing to migrate.
|
||||
- [x] Spec 432 regression tests pass.
|
||||
- [x] Spec 431 regression tests pass.
|
||||
- [x] Spec 430 regression tests pass.
|
||||
- [x] Selected Spec 426/427/417/419/420 regressions pass where available.
|
||||
- [x] Provider capability registry/evaluator regressions pass.
|
||||
- [x] Focused browser proof for existing provider capability/readiness surfaces passes.
|
||||
- [x] Human Product Sanity for existing provider capability/readiness surfaces is documented.
|
||||
- [x] Pint passes.
|
||||
- [x] `git diff --check` passes.
|
||||
- [x] Final `git status --short` is documented.
|
||||
|
||||
## Implementation Report Required Matrices
|
||||
|
||||
- [x] Credential kind matrix.
|
||||
- [x] Permission evidence matrix.
|
||||
- [x] Migration/no-migration matrix.
|
||||
- [x] No-promotion matrix.
|
||||
- [x] Product Surface proof, browser proof, and Human Product Sanity close-out.
|
||||
- [x] Livewire v4 / provider registration / global search / destructive action / asset / deployment impact close-out.
|
||||
- [x] No completed-spec rewrite assertion.
|
||||
|
||||
## Final Candidate Gate
|
||||
|
||||
Preparation result: PASS.
|
||||
|
||||
Implementation PASS requires all applicable unchecked runtime items above to be checked with evidence. PASS WITH CONDITIONS is allowed only when remaining conditions do not weaken credential safety, permission evidence safety, provider capability accuracy, runner gate safety, redaction, ownership, no-evidence posture, no-claim posture, or no-mini-platform posture.
|
||||
|
||||
FAIL if client-secret enables Exchange PowerShell, admin consent alone enables `exchange_powershell_invoke`, unsupported/stale/wrong-scope evidence passes, credential material is stored or logged, provider capability overclaims Supported, Exchange provider calls occur, PowerShell execution occurs, tenant configuration evidence is created, new UI/routes/jobs/schedules/listeners are added, existing provider readiness surfaces default-render raw payloads, `tenant_id` ownership truth is introduced, or tests do not prove credential/permission evidence safety.
|
||||
@ -0,0 +1,160 @@
|
||||
# Implementation Report: Exchange Credential and Permission Evidence Support
|
||||
|
||||
## Result
|
||||
|
||||
- **Gate result**: PASS WITH CONDITIONS.
|
||||
- **Active spec**: `specs/433-exchange-credential-permission-evidence-support/`.
|
||||
- **Branch**: `433-exchange-credential-permission-evidence-support`.
|
||||
- **HEAD at implementation start**: `f4e34212 feat: add Exchange PowerShell production runner gate (#499)`.
|
||||
- **Initial dirty state**: only untracked active Spec 433 package.
|
||||
- **Final dirty state**: application/config/test changes plus the active Spec 433 package and this report.
|
||||
- **Condition**: browser Product Surface sanity found an existing broader provider-readiness guidance card can still show a generic provider credential blocker next to the Exchange-specific capability result. The changed Exchange capability slots themselves were canonical and redacted. No UI code was changed because this spec forbids new UI/surface work.
|
||||
|
||||
## Activated Skills and Gates
|
||||
|
||||
- `spec-kit-implementation-loop`: active implementation workflow.
|
||||
- `pest-testing`: Pest 4 focused unit/feature coverage.
|
||||
- Browser skills: `browser:control-in-app-browser` and repo `browsertest` for focused read-only browser proof.
|
||||
- TenantPilot repo gates: spec readiness, workspace-scope safety, RBAC/action safety, provider freshness semantics, OperationRun truth, evidence-anchor contract, Product Surface gate, Filament/Livewire v5 change loop.
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
- Added `ExchangePowerShellInvocationReadinessEvaluator` as the shared readiness truth for `exchange_powershell_invoke` capability evaluation and the production runner gate.
|
||||
- Kept the no-migration path. Existing `ProviderCredential` fields and `ManagedEnvironmentPermission.details` are sufficient when permission details explicitly match workspace, managed environment, provider, and provider connection.
|
||||
- Tightened certificate readiness so certificate references require both rotation and expiry metadata.
|
||||
- Extended `ExchangePowerShellPermissionEvidenceEvaluator` with supported-provider checks, status normalization, configurable currentness, safe source metadata, scoped provider-connection validation, and sanitized blocker states.
|
||||
- Post-review hardening requires trusted permission evidence provenance: `source=provider_verification`, canonical, calendar-valid ISO/RFC3339 non-future `observed_at`/`verified_at`, `verified_at >= observed_at`, evaluator identity/version metadata, and current `last_checked_at` plus current observed/verified timestamps. Missing, malformed, relative, untrusted, or time-inconsistent metadata now fails closed as unvalidated evidence; stale row or provenance timestamps fail closed as stale evidence.
|
||||
- Post-review scope hardening requires `workspace_id`, `managed_environment_id`, and `provider_connection_id` evidence metadata to be canonical positive integer values before any scope comparison. Numeric-prefix, decimal, scientific-notation, whitespace-padded, missing, or otherwise malformed scope IDs now fail closed as unvalidated evidence instead of passing through PHP integer coercion.
|
||||
- Updated `ProviderCapabilityEvaluator` so `exchange_powershell_invoke` is Supported only when combined credential and permission readiness passes.
|
||||
- Post-review hardening makes the shared Exchange readiness evaluator the single capability truth for `exchange_powershell_invoke`; the capability no longer applies a separate generic provider-readiness freshness precheck that can diverge from the Exchange evidence currentness config.
|
||||
- Updated `ExchangePowerShellProductionRunner` to consume combined readiness before runtime checks or process execution.
|
||||
- Added safe `readiness_state` and `readiness_blocker` propagation into OperationRun runner-result context.
|
||||
- Added env/config defaults for `TENANTPILOT_EXCHANGE_POWERSHELL_PERMISSION_EVIDENCE_CURRENTNESS_DAYS=30`.
|
||||
|
||||
## Data Model Decision
|
||||
|
||||
| Area | Decision | Proof |
|
||||
| --- | --- | --- |
|
||||
| Provider credential | No migration | `provider_connection_id`, `credential_kind`, `source`, `last_rotated_at`, and `expires_at` cover the observable readiness states for this slice. |
|
||||
| Permission evidence | No migration | `ManagedEnvironmentPermission` remains one row per environment + permission key; `details` must match workspace, environment, provider, and provider connection or fail closed. |
|
||||
| Provider connection scope | Existing model | Readiness requires matching `workspace_id` and `managed_environment_id`; wrong connection details block. |
|
||||
| Secret material | No new storage | Existing encrypted payload remains hidden and is never returned in readiness/OperationRun context. |
|
||||
| Migration lane | N/A | No schema file added; no PostgreSQL schema lane required. |
|
||||
|
||||
## Credential Matrix
|
||||
|
||||
| Credential state | Result |
|
||||
| --- | --- |
|
||||
| Missing reference | Blocks as `credential_blocked_missing_reference`. |
|
||||
| Client secret | Blocks as `credential_blocked_secret_based_app`. |
|
||||
| Certificate missing rotation or expiry metadata | Blocks as `credential_blocked_certificate_missing`. |
|
||||
| Expired certificate | Blocks as `credential_blocked_certificate_expired`. |
|
||||
| Certificate unsupported by config | Blocks as `credential_blocked_certificate_unsupported`. |
|
||||
| Certificate explicitly supported for readiness | Passes as `certificate_supported`. |
|
||||
| Federated | Blocks unless future config/support is added. |
|
||||
| Managed identity string | Blocks as unsupported/unknown. |
|
||||
| Unknown kind | Blocks fail-closed. |
|
||||
|
||||
## Permission Matrix
|
||||
|
||||
| Permission evidence state | Result |
|
||||
| --- | --- |
|
||||
| Missing row | Blocks as `permission_evidence_blocked_missing`. |
|
||||
| Missing/absent/revoked status | Blocks as `permission_evidence_blocked_absent`. |
|
||||
| Unknown/error status | Blocks as `permission_evidence_blocked_unknown`. |
|
||||
| Granted without trusted source, observed/verified timestamps, evaluator identity, or evaluator version | Blocks as unvalidated. |
|
||||
| Granted with malformed, relative, future, or time-inconsistent provenance metadata | Blocks as unvalidated. |
|
||||
| Granted with malformed scope identifiers | Blocks as unvalidated before scope matching. |
|
||||
| Stale row, observed timestamp, or verified timestamp beyond configured currentness | Blocks as `permission_evidence_blocked_stale`. |
|
||||
| Wrong workspace/environment/provider/provider connection | Blocks with scoped failure code. |
|
||||
| Current scoped `Exchange.ManageAsApp` | Passes as `verified`. |
|
||||
|
||||
## Capability and Runner Proof
|
||||
|
||||
- `exchange_powershell_invoke` uses the combined readiness evaluator and no longer relies on admin consent, generic provider freshness, or generic stored permission rows alone.
|
||||
- Missing credential maps to Missing; stale/unknown/unvalidated permission evidence maps to Unknown; wrong-scope or unsupported readiness maps to Blocked.
|
||||
- The production runner evaluates combined readiness before runtime readiness, command building, locks, or process execution.
|
||||
- Successful runner context persists only safe state: runner mode, execution enabled, readiness state, runtime state, credential state, permission evidence state, command builder/output/concurrency state, counts, and duration.
|
||||
|
||||
## No-Promotion Proof
|
||||
|
||||
- No `TenantConfigurationResource` or `TenantConfigurationResourceEvidence` rows are created.
|
||||
- No raw or normalized Exchange payload, compare/render/certification/restore/customer-ready state, report output, Review Pack output, PDF output, route, Filament page/resource/widget/action, Livewire component, navigation, asset, global search change, job, schedule, listener, console trigger, Exchange-specific table, legacy shim, fallback reader, dual write, or `tenant_id` ownership path was introduced.
|
||||
- Completed Specs 429-432 were read-only context and were not rewritten.
|
||||
|
||||
## Product Surface and Filament Close-Out
|
||||
|
||||
- **No-legacy posture**: canonical addition only; no aliases, fallback readers, hidden routes, duplicate UI, or historical compatibility path.
|
||||
- **Product Surface Impact**: existing provider capability/readiness surfaces can render changed evaluator output; no new surface budget introduced.
|
||||
- **UI Surface Impact**: no UI files changed; existing provider connection and required-permissions surfaces render the changed status data.
|
||||
- **Browser proof**: PASS WITH CONDITION. See browser section below.
|
||||
- **Human Product Sanity**: PASS WITH CONDITION. Exchange capability slots are canonical/redacted; existing broader readiness card can still show a generic credential blocker beside Exchange-specific Supported.
|
||||
- **Visible complexity outcome**: neutral; no new visible components, only more truthful existing status values.
|
||||
- **Livewire v4 compliance**: unchanged repo baseline; no Livewire runtime code added.
|
||||
- **Provider registration location**: no Filament panel provider registration changed; Laravel panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- **Global search**: no Filament resources or global search behavior changed.
|
||||
- **Destructive/high-impact actions**: no UI action added; runner remains behind existing Spec 432 gates and provider-run authorization.
|
||||
- **Asset strategy**: no assets; `filament:assets` not required for this slice.
|
||||
- **Deployment impact**: no migration, queue, schedule, storage, asset, or build impact. New env example key: `TENANTPILOT_EXCHANGE_POWERSHELL_PERMISSION_EVIDENCE_CURRENTNESS_DAYS=30`.
|
||||
|
||||
## Browser Proof
|
||||
|
||||
- Sail was running and migrations were current. A local smoke fixture was seeded with:
|
||||
- ready environment + certificate credential + scoped current `Exchange.ManageAsApp` evidence + all normal required permission rows;
|
||||
- blocked environment + client-secret credential + stale permission evidence.
|
||||
- A temporary ignored local `.env` override `TENANTPILOT_EXCHANGE_POWERSHELL_SUPPORTED_REFERENCE_KINDS=certificate` was used only during browser proof and removed afterward; config cache was cleared after removal.
|
||||
- Provider connection view, ready fixture:
|
||||
- `Exchange PowerShell invocation: Supported` rendered in existing capability detail.
|
||||
- No raw grant, access token, private key, certificate password, or client secret sentinel rendered.
|
||||
- No browser console errors from the tab.
|
||||
- Provider connection view, blocked fixture:
|
||||
- `Exchange PowerShell invocation: Unknown` rendered for the blocked client-secret/stale-evidence fixture.
|
||||
- No raw token/client-secret sentinel rendered.
|
||||
- No browser console errors from the tab.
|
||||
- Required-permissions view, ready fixture:
|
||||
- `Provider capabilities` section rendered `Exchange PowerShell invocation` as Supported with `0 missing, 0 need review`.
|
||||
- No raw grant, access token, private key, certificate password, or client secret sentinel rendered.
|
||||
- No browser console errors from the tab.
|
||||
- Required-permissions view, blocked fixture:
|
||||
- Required-permissions/provider capability surface stayed non-supported and showed missing/stale guidance rather than ready.
|
||||
- No raw token/client-secret sentinel rendered.
|
||||
- No browser console errors from the tab.
|
||||
- Laravel Boost browser log contained older unrelated June operation-page errors, not Spec 433 route errors.
|
||||
|
||||
## Validation
|
||||
|
||||
- Post-review remediation:
|
||||
- `cd apps/platform && php artisan test --filter=Spec433 --compact`
|
||||
- Result: PASS, 48 tests, 211 assertions.
|
||||
- `cd apps/platform && php artisan test --filter='Spec433|Spec432|Spec431' --compact`
|
||||
- Result: PASS, 150 tests, 1057 assertions.
|
||||
- `cd apps/platform && ./vendor/bin/pint --dirty`
|
||||
- Result: PASS, 10 files.
|
||||
- `git diff --check`
|
||||
- Result: PASS.
|
||||
- `cd apps/platform && php artisan test --filter=Spec433 --compact`
|
||||
- Result: PASS, 30 tests, 157 assertions.
|
||||
- `cd apps/platform && php artisan test --filter=Spec432 --compact`
|
||||
- Result: PASS, 72 tests, 430 assertions.
|
||||
- `cd apps/platform && php artisan test --filter=Spec431 --compact`
|
||||
- Result: PASS, 30 tests, 416 assertions.
|
||||
- `cd apps/platform && php artisan test --filter='Spec433|Spec432|Spec431' --compact`
|
||||
- Result: PASS, 132 tests, 1003 assertions.
|
||||
- `cd apps/platform && php artisan test --filter='Spec433|Spec432|Spec431|Spec430|Spec427ExchangeTeamsNoEvidencePromotion|Spec427ExchangeTeamsNoCompareRenderCertification|Spec426ExchangeTeamsNoTenantId|Spec426ExchangeTeamsNoMiniPlatform|Spec417IdentityNoLegacyNoUiActivation|Spec419M365RegistryExpansion|Spec420M365NoOverclaim|ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact`
|
||||
- Result: PASS, 222 tests, 2028 assertions, 1 PostgreSQL-only skip.
|
||||
- `cd apps/platform && ./vendor/bin/pint --dirty`
|
||||
- Result: PASS, 10 files.
|
||||
- `cd apps/platform && ./vendor/bin/pint app/Services/TenantConfiguration/ExchangePowerShellInvocationReadinessEvaluator.php tests/Feature/TenantConfiguration/Spec433ExchangePowerShellEvidenceReadinessTest.php tests/Feature/TenantConfiguration/Spec431ExchangePowerShellInvocationGateTest.php tests/Feature/TenantConfiguration/Spec432ExchangePowerShellProductionRunnerGateTest.php --format=txt`
|
||||
- Result: PASS.
|
||||
- `git diff --check`
|
||||
- Result: PASS.
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan migrate --force`
|
||||
- Result: PASS, nothing to migrate.
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan config:clear`
|
||||
- Result: PASS after browser override removal.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- Content-backed Exchange evidence capture/promotion remains Spec 434 or later.
|
||||
- Live Exchange execution success, customer claims, compare/render/certification/restore readiness, Teams, Exchange Admin API, broader Exchange targets, and UI productization remain out of scope.
|
||||
- A future UI/product-surface cleanup should decide how to distinguish generic provider readiness from Exchange PowerShell-specific capability readiness on provider connection pages.
|
||||
@ -0,0 +1,343 @@
|
||||
# Implementation Plan: Exchange Credential and Permission Evidence Support
|
||||
|
||||
**Branch**: `433-exchange-credential-permission-evidence-support` | **Date**: 2026-07-07 | **Spec**: `specs/433-exchange-credential-permission-evidence-support/spec.md`
|
||||
**Input**: Feature specification from `specs/433-exchange-credential-permission-evidence-support/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Refine Exchange PowerShell credential and permission evidence readiness for the existing Spec 432 production runner gate and existing provider capability/readiness evaluation. The implementation should make credential evidence explicit, provider-scoped, workspace-scoped, managed-environment-scoped, current, auditable, and redaction-safe; make `Exchange.ManageAsApp` permission evidence current and scoped; introduce or reuse a combined readiness result; and ensure `exchange_powershell_invoke` is Supported only when both credential and permission evidence pass. No Exchange calls, PowerShell execution, content-backed evidence, new UI/routes/navigation/assets/global search, jobs, schedules, listeners, reports, customer claims, or `tenant_id` are in scope. Existing provider capability/readiness surfaces can render changed status output through existing code paths and must be browser-checked.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 / Laravel 12
|
||||
**Primary Dependencies**: Existing Laravel monolith, Filament v5 / Livewire v4 baseline unchanged
|
||||
**Storage**: PostgreSQL. Default is no new table; possible minimal provider-agnostic migration only if existing metadata is insufficient.
|
||||
**Testing**: Pest 4 focused unit and feature tests
|
||||
**Validation Lanes**: fast-feedback/confidence focused tests; PostgreSQL lane if migration/JSONB/index behavior changes; focused read-only browser proof for existing provider capability/readiness surfaces
|
||||
**Target Platform**: Laravel Sail local; Dokploy staging/production with Exchange production runner disabled by default
|
||||
**Project Type**: Laravel monolith under `apps/platform`
|
||||
**Performance Goals**: Readiness checks must be local DB/config evaluation only; no provider calls, process execution, or render-time work
|
||||
**Constraints**: No secrets/raw payloads in context/logs/tests; no Exchange execution; no new UI/routes/navigation/assets/global search; no customer claims; no `tenant_id`
|
||||
**Scale/Scope**: Existing Exchange PowerShell invocation operation and `exchange_powershell_invoke` capability for Spec 430 target slice only
|
||||
|
||||
## Preflight Findings
|
||||
|
||||
- Current branch before Spec Kit execution: `platform-dev`.
|
||||
- HEAD before Spec Kit execution: `f4e34212 feat: add Exchange PowerShell production runner gate (#499)`.
|
||||
- Initial dirty state before Spec Kit execution: clean.
|
||||
- Spec Kit helper created branch: `433-exchange-credential-permission-evidence-support`.
|
||||
- No existing `specs/433-*` directory or `*433*` branch was found before preparation.
|
||||
- Spec 431's historical "Spec 433" label for content-backed evidence promotion is superseded by the Spec 432 follow-up decision; content-backed promotion is now Spec 434.
|
||||
- `docs/product/spec-candidates.md` marks the automatic next-best-prep queue empty/manual-only; this spec is selected because the user directly provided a P0 draft.
|
||||
- Spec 432 implementation report records PASS and proves the runner boundary, disabled default, production config gate, runtime checker, credential resolver, permission evaluator, process executor, command builder/output guards, OperationRun context sanitization, no evidence, no UI, no migration, and no `tenant_id`.
|
||||
- Current `apps/platform/config/tenantpilot.php` includes:
|
||||
- `tenantpilot.features.exchange_powershell_invocation`
|
||||
- `tenantpilot.features.exchange_powershell_production_runner`
|
||||
- `tenantpilot.exchange_powershell.credentials.supported_reference_kinds`
|
||||
- runtime knobs under `tenantpilot.exchange_powershell.runtime`
|
||||
- Current `ProviderCredential` has `provider_connection_id`, `type`, `credential_kind`, `source`, encrypted hidden `payload`, `last_rotated_at`, and `expires_at`.
|
||||
- Current `ProviderCredentialKind` has `client_secret`, `certificate`, and `federated`; managed identity is not a first-class enum case.
|
||||
- Current `ManagedEnvironmentPermission` has `workspace_id`, `managed_environment_id`, `permission_key`, `status`, `last_checked_at`, and JSONB `details`; current persistence is unique by `managed_environment_id` + `permission_key`, with provider-connection scope represented only through `details`.
|
||||
- Current `ExchangePowerShellCredentialReferenceResolver` blocks missing credentials, client-secret credentials, expired certificates, unsupported certificate/federated/managed-identity states, and unknown kinds, but does not yet prove all first-class accessibility/wrong-scope/source/currentness states from the draft.
|
||||
- Current `ExchangePowerShellPermissionEvidenceEvaluator` uses `Exchange.ManageAsApp`, `last_checked_at`, and scoped details with a hard-coded 30-day threshold.
|
||||
- Current `ProviderCapabilityRegistry` defines `exchange_powershell_invoke` with provider requirement keys `provider.exchange_powershell_invocation` and `permissions.admin_consent`.
|
||||
- Current `ProviderCapabilityEvaluator` has a stored Exchange permission fallback and hard-coded 30-day currentness behavior.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: existing provider capability/readiness status presentation only.
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: existing Provider Connection capability/readiness presentation, existing Environment Required Permissions provider-capability section, and existing verification-assist provider-capability presentation where they render provider capability/readiness output.
|
||||
- **No-impact class, if applicable**: no new route, page, component, navigation, action, asset, global-search behavior, report, dashboard, or customer surface is introduced.
|
||||
- **Native vs custom classification summary**: reuse existing Filament/Blade rendering and existing provider capability/readiness status vocabulary; do not add a runtime Product Surface framework.
|
||||
- **Shared-family relevance**: provider capability/readiness and OperationRun context; existing rendered provider-readiness family must remain canonical and redacted.
|
||||
- **State layers in scope**: credential evidence state, permission evidence state, combined readiness state, provider capability status, runner-gate blocker state, OperationRun context.
|
||||
- **Audience modes in scope**: internal operator/reviewer through existing provider readiness/capability surfaces; no customer/read-only claim surface.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: default surfaces show only canonical readiness/capability result and safe high-level blocker labels. Raw credential details, permission `details`, provider payloads, OperationRun context, stdout/stderr, IDs used only for scope checks, and support diagnostics stay demoted from default views.
|
||||
- **Raw/support gating plan**: Raw credential material, raw permission payloads, raw provider payloads, and sensitive exception messages are forbidden from context/loggable output and default rendered surfaces.
|
||||
- **One-primary-action / duplicate-truth control**: no new primary action; provider capability and runner gate must consume the same readiness truth.
|
||||
- **Handling modes by drift class or surface**: status-only change on existing surfaces; any new UI file, route, or action requires spec/plan amendment before implementation continues.
|
||||
- **Repository-signal treatment**: Filament/provider panel file changes are out of scope unless the spec is amended. Service/provider binding changes are product-surface relevant when their output is rendered by existing surfaces.
|
||||
- **Special surface test profiles**: focused read-only browser smoke for existing provider capability/readiness surfaces.
|
||||
- **Required tests or manual smoke**: focused browser proof plus Human Product Sanity for the existing provider capability/readiness surfaces after implementation.
|
||||
- **Exception path and spread control**: none.
|
||||
- **Active feature PR close-out entry**: credential and permission evidence readiness guardrail, existing provider capability/readiness surfaces browser-checked, no new rendered UI surface introduced.
|
||||
- **UI/Productization coverage decision**: existing page/status presentation changed.
|
||||
- **Coverage artifacts to update**: implementation report only.
|
||||
- **No-impact rationale**: N/A - existing rendered provider readiness/capability surfaces are affected by evaluator output.
|
||||
- **Navigation / Filament provider-panel handling**: no panel/provider registration changes; Laravel panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- **Screenshot or page-report need**: focused browser screenshot/proof for the existing affected surfaces.
|
||||
|
||||
## Product Surface Contract Plan
|
||||
|
||||
- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`.
|
||||
- **No-legacy posture**: canonical addition/replacement only; no aliases, fallback readers, duplicate UI, hidden routes, or historical fixtures.
|
||||
- **Page archetype and surface budget plan**: existing provider readiness/capability surfaces; no new panel, table, action, or diagnostic drawer.
|
||||
- **Technical Annex and deep-link demotion plan**: OperationRun remains internal execution truth and is not customer proof. Raw evidence, credential details, permission `details`, provider diagnostics, and logs are not default-visible.
|
||||
- **Canonical status vocabulary plan**: reuse existing `ProviderCapabilityStatus` and provider-readiness vocabulary unless a new behavior-backed state passes proportionality review.
|
||||
- **Product Surface exceptions**: none.
|
||||
- **Browser verification plan**: focused read-only smoke for existing provider capability/readiness surfaces.
|
||||
- **Human Product Sanity plan**: required for existing provider readiness/capability status presentation.
|
||||
- **Visible complexity outcome target**: neutral-to-reduced; more truthful readiness without extra default-visible technical detail.
|
||||
- **Implementation report target**: `specs/433-exchange-credential-permission-evidence-support/implementation-report.md`.
|
||||
|
||||
## Filament / Livewire / Deployment Posture
|
||||
|
||||
- **Livewire v4 compliance**: repo baseline remains Livewire v4; no Livewire runtime code planned.
|
||||
- **Panel provider registration location**: no panel/provider change; Laravel panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- **Global search posture**: no Filament resource or global search behavior changed.
|
||||
- **Destructive/high-impact action posture**: no UI action added. Backend runner readiness remains read-only gating. Any live invocation remains controlled by existing Spec 432 gates and provider-run authorization.
|
||||
- **Asset strategy**: no assets; `filament:assets` is not required for this slice.
|
||||
- **Testing plan**: no pages/widgets/relation managers/actions are added; focused browser proof covers existing provider capability/readiness surfaces because evaluator output is rendered there.
|
||||
- **Deployment impact**: possible new fail-closed config key for permission currentness only; possible provider-agnostic migration only if existing metadata is insufficient. No queues, schedules, storage, assets, or browser build impact.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes.
|
||||
- **Systems touched**:
|
||||
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCredentialReferenceResolver.php`
|
||||
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellPermissionEvidenceEvaluator.php`
|
||||
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProductionRunner.php`
|
||||
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationGate.php`
|
||||
- possible new `ExchangePowerShellInvocationReadinessEvaluator.php`
|
||||
- `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php`
|
||||
- `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php` only if requirement keys/status behavior need adjustment
|
||||
- `apps/platform/app/Models/ProviderCredential.php`
|
||||
- `apps/platform/app/Support/Providers/ProviderCredentialKind.php` only if managed identity becomes first-class
|
||||
- `apps/platform/app/Models/ManagedEnvironmentPermission.php`
|
||||
- `apps/platform/config/tenantpilot.php`
|
||||
- **Shared abstractions reused**: provider credential/reference model, managed-environment permission rows, provider capability evaluator/registry, OperationRun lifecycle/context safety, Spec 432 runner gate.
|
||||
- **New abstraction introduced? why?**: A combined readiness evaluator may be introduced so provider capability and runner gate consume one evidence truth. This is justified by security/readiness correctness and avoids duplicated local checks.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: Existing resolvers/evaluators already block many unsafe cases, but readiness is split between credential resolver, permission evaluator, provider capability fallback, and production runner. Spec 433 should remove drift and add missing evidence metadata/currentness/accessibility proof.
|
||||
- **Bounded deviation / spread control**: No generic provider readiness framework, no Exchange mini-platform, no UI pattern, no evidence writer, no customer-output path.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, backend context safety only.
|
||||
- **Central contract reused**: existing Spec 432 invocation path and `OperationRunService`.
|
||||
- **Delegated UX behaviors**: N/A - no toast, run link, artifact link, browser event, queued DB notification, dedupe messaging, or surface-owned URL resolution.
|
||||
- **Surface-owned behavior kept local**: none.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism.
|
||||
- **Exception path**: none.
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes.
|
||||
- **Provider-owned seams**: Exchange PowerShell credential support, `Exchange.ManageAsApp`, Exchange invocation readiness, Exchange-specific blocker naming where bounded.
|
||||
- **Platform-core seams**: provider connection scope, credential-reference metadata, managed-environment permission evidence, provider capability, OperationRun context, currentness/source metadata, redaction.
|
||||
- **Neutral platform terms / contracts preserved**: provider connection, credential reference, permission evidence, currentness, observed/verified/source metadata, readiness, workspace, managed environment, blocker reason.
|
||||
- **Retained provider-specific semantics and why**: `Exchange.ManageAsApp` and Exchange PowerShell invocation are retained because this is an Exchange runner readiness spec, not a multi-provider abstraction.
|
||||
- **Bounded extraction or follow-up path**: document-in-feature. If evidence metadata proves generally useful, it must remain provider-agnostic. Content-backed evidence promotion remains Spec 434.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- Inventory-first: no inventory, snapshot, backup, or tenant configuration evidence truth is created.
|
||||
- Read/write separation: no provider writes, no Exchange calls, no PowerShell execution. Gating remains read-only local evaluation.
|
||||
- Graph contract path: no Graph calls are added and no Graph contract path changes.
|
||||
- Deterministic capabilities: `exchange_powershell_invoke` must be deterministically evaluated from readiness evidence and tested.
|
||||
- RBAC-UX: implementation must preserve workspace/environment entitlement and provider-run capability behavior from Spec 432; non-member/wrong scope 404; member missing capability 403 or repo-equivalent.
|
||||
- Workspace isolation: provider connection, credential reference, permission evidence, and readiness must all resolve within the same workspace.
|
||||
- Tenant/managed-environment isolation: environment-owned permission evidence must match `managed_environment_id`; provider connection must belong to the same environment.
|
||||
- OperationRun truth: OperationRun remains execution truth only and cannot become evidence/customer proof.
|
||||
- OperationRun context: safe readiness/blocker state only; no credential material, raw permission payload, raw provider output, or sensitive exceptions.
|
||||
- Ops-UX summary counts: no new summary count key expected. If one is unavoidable, update `OperationSummaryKeys::all()` and test.
|
||||
- Provider freshness semantics: stale permission evidence blocks and is not labeled ready.
|
||||
- Evidence anchor contract: no tenant configuration evidence rows or evidence anchors are created.
|
||||
- Product Surface Contract: applies because existing provider capability/readiness surfaces render changed evaluator output; no new UI surface is introduced.
|
||||
- Test governance: focused unit/feature tests; PostgreSQL lane if schema changes; focused browser lane for existing provider capability/readiness surfaces.
|
||||
- Proportionality: any new metadata, state, or evaluator must solve current safety risk and be narrower than a generic provider framework.
|
||||
- No premature abstraction: no generic PowerShell/provider readiness framework until more real cases require it.
|
||||
- Persisted truth: migration only if evidence metadata must outlive the originating run and cannot be safely represented today.
|
||||
- Behavioral state: each new state must block/allow execution, change capability support, drive audit/readiness proof, or protect redaction.
|
||||
- Provider boundary: provider-specific Exchange terms stay in provider-owned seams and do not become platform-core ownership truth.
|
||||
- Pre-production lean doctrine: replace ambiguous readiness behavior cleanly; no compatibility aliases or fallback readers.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit tests for pure readiness/evaluator behavior; Feature tests for provider capability, runner-gate integration, OperationRun context safety, no-promotion guards, and migration/no-migration proof.
|
||||
- **Affected validation lanes**: fast-feedback/confidence; PostgreSQL if migration/JSONB/index behavior changes; focused read-only browser proof for existing provider capability/readiness surfaces.
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The change is evidence-readiness and local gate evaluation with no live provider calls; focused browser proof is needed because provider capability/readiness output is rendered by existing surfaces.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec433 --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec432 --compact`
|
||||
- selected Spec 431/430/426/427/417/419/420 and provider capability regressions
|
||||
- PostgreSQL lane if migration is added
|
||||
- focused read-only browser proof for existing provider capability/readiness surfaces
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: workspace, managed environment, provider connection, provider credential, permission evidence, currentness thresholds, OperationRun context. Keep setup explicit and opt-in.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no planned defaults.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: focused browser proof for existing provider capability/readiness surfaces; PostgreSQL validation only if migration requires it.
|
||||
- **Surface-class relief / special coverage rule**: no new rendered surface, but existing rendered readiness status requires browser proof and Human Product Sanity.
|
||||
- **Closing validation and reviewer handoff**: implementation report must list exact commands, pass/fail counts, dirty state, and any safe residual conditions.
|
||||
- **Budget / baseline / trend follow-up**: none expected.
|
||||
- **Review-stop questions**: verify no Exchange call, no PowerShell execution, no evidence rows, no new UI, no trigger surface, no credential material, no raw permission payload, no `tenant_id`, no Exchange-specific table, no provider capability overclaim, and no raw details on existing provider readiness surfaces.
|
||||
- **Escalation path**: reject-or-split if implementation attempts content-backed capture, live invocation, UI/routes/jobs/schedules/listeners, customer output, broad metadata framework, or Exchange-specific persistence.
|
||||
- **Active feature PR close-out entry**: credential and permission evidence readiness guardrail with existing provider capability/readiness surfaces browser-checked and no new rendered UI surface introduced.
|
||||
- **Why no dedicated follow-up spec is needed**: Routine readiness hardening belongs in Spec 433. Content-backed Exchange evidence and customer output are separate later specs.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/433-exchange-credential-permission-evidence-support/
|
||||
|-- spec.md
|
||||
|-- plan.md
|
||||
|-- tasks.md
|
||||
|-- checklists/
|
||||
| `-- requirements.md
|
||||
`-- implementation-report.md # created by later implementation loop
|
||||
```
|
||||
|
||||
### Source Code (likely affected by later implementation)
|
||||
|
||||
```text
|
||||
apps/platform/app/Models/ProviderCredential.php
|
||||
apps/platform/app/Models/ManagedEnvironmentPermission.php
|
||||
apps/platform/app/Support/Providers/ProviderCredentialKind.php
|
||||
apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php
|
||||
apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCredentialReferenceResolver.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellPermissionEvidenceEvaluator.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProductionRunner.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationGate.php
|
||||
apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationReadinessEvaluator.php
|
||||
apps/platform/config/tenantpilot.php
|
||||
apps/platform/tests/Unit/Support/TenantConfiguration/
|
||||
apps/platform/tests/Feature/TenantConfiguration/
|
||||
apps/platform/tests/Feature/Providers/
|
||||
```
|
||||
|
||||
Possible migration files only if existing metadata is insufficient:
|
||||
|
||||
```text
|
||||
apps/platform/database/migrations/*_add_provider_credential_evidence_metadata.php
|
||||
apps/platform/database/migrations/*_add_managed_environment_permission_evidence_metadata.php
|
||||
```
|
||||
|
||||
Forbidden source changes:
|
||||
|
||||
```text
|
||||
apps/platform/routes/**
|
||||
apps/platform/app/Filament/**
|
||||
apps/platform/app/Livewire/**
|
||||
apps/platform/resources/views/**
|
||||
apps/platform/app/Jobs/**
|
||||
apps/platform/app/Listeners/**
|
||||
apps/platform/app/Console/**
|
||||
customer report/review-pack/PDF output code
|
||||
new Exchange-specific credential or permission tables
|
||||
tenant_id ownership columns
|
||||
legacy shim or fallback reader paths
|
||||
```
|
||||
|
||||
**Structure Decision**: Use the existing TenantConfiguration service path and provider capability/readiness paths. Do not create a new Exchange subsystem or base folder without amending the spec.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
| --- | --- | --- |
|
||||
| Combined readiness evaluator | Capability and runner gate must share one readiness truth. | Keeping separate local checks risks Supported capability while runner blocks, or runner pass while capability is unknown. |
|
||||
| New credential/permission blocker states | Each state changes execution/capability behavior and redaction/audit interpretation. | Collapsing to `unknown` hides missing vs expired vs inaccessible vs wrong-scope safety conditions. |
|
||||
| Possible provider-agnostic metadata migration | Only if current fields cannot safely represent source/observed/verified/accessibility/currentness. | Exchange-specific tables or free-form implicit details would deepen provider coupling or weaken query/audit safety. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Prevent false readiness and unsafe live invocation for Exchange PowerShell before evidence capture is allowed.
|
||||
- **Existing structure is insufficient because**: Current code blocks many cases, but readiness is split across resolvers/evaluators and some evidence metadata is implicit or hard-coded.
|
||||
- **Narrowest correct implementation**: Refine existing provider credential, permission evidence, capability, and runner gate paths. Add one combined readiness result if needed.
|
||||
- **Ownership cost created**: Focused service/test surface and possible small provider-agnostic metadata upkeep.
|
||||
- **Alternative intentionally rejected**: Admin-consent-only support, client-secret support, Exchange-specific persistence, UI readiness labels, and evidence promotion.
|
||||
- **Release truth**: Current-release safety prerequisite for Spec 434.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0 - Preflight
|
||||
|
||||
- Capture branch, HEAD, and dirty state.
|
||||
- Confirm Spec 432 implementation report remains PASS with no open merge-blocking runner safety findings.
|
||||
- Confirm current code paths for credential resolver, permission evaluator, capability evaluator, runner gate, credential model, permission model, and config.
|
||||
- Confirm no Exchange calls, PowerShell execution, evidence promotion, new UI, trigger surface, customer output, migration, or `tenant_id` is planned before the data-model decision.
|
||||
- Confirm the existing provider capability/readiness surfaces that render `ProviderCapabilityEvaluator`/registry output and will need focused browser proof after implementation.
|
||||
|
||||
### Phase 1 - Data Model Decision
|
||||
|
||||
- Inspect `ProviderCredential`, `ProviderCredentialKind`, `ManagedEnvironmentPermission`, current migrations, and current evaluator behavior.
|
||||
- Decide no-migration versus minimal provider-agnostic metadata migration.
|
||||
- If no migration, document how current fields/details safely represent accessibility, source, observed, verified, currentness, and scope, including the current `managed_environment_id` + `permission_key` uniqueness and provider-connection matching through `details`.
|
||||
- If no migration, prove nonmatching or missing `details.provider_connection_id` fails closed and no fallback-to-latest/admin-consent-only behavior exists.
|
||||
- If migration is required, stop runtime implementation and update `spec.md` plus this `plan.md` with exact fields, ownership semantics, constraints/indexes, rollback posture, and proportionality review before adding schema code.
|
||||
- After an approved migration amendment, add tests proving provider-agnostic fields, reversibility where practical, no secret material, no raw payloads, no Exchange-specific table, and no `tenant_id`.
|
||||
|
||||
### Phase 2 - Credential Evidence
|
||||
|
||||
- Extend or refactor `ExchangePowerShellCredentialReferenceResolver` to return explicit credential evidence states and safe context.
|
||||
- Keep client-secret blocked.
|
||||
- Add distinct inaccessible state only when observable from repo-owned fields or an approved metadata migration; otherwise collapse to a safe fail-closed state with tests.
|
||||
- Add wrong-scope/unvalidated/currentness states when supported by repo truth; do not invent fake precision from unavailable credential metadata.
|
||||
- Decide certificate support versus explicit safe block.
|
||||
- Decide federated support versus explicit safe block.
|
||||
- Decide managed identity support versus explicit safe block and whether a first-class enum case is warranted.
|
||||
- Add redaction tests for credential payloads, references, exception messages, and OperationRun/loggable output.
|
||||
|
||||
### Phase 3 - Permission Evidence
|
||||
|
||||
- Extend or refactor `ExchangePowerShellPermissionEvidenceEvaluator` to make `Exchange.ManageAsApp` source/currentness/scope metadata first-class enough for the gate.
|
||||
- Make currentness threshold configurable or explicitly justify/test the fixed 30-day threshold.
|
||||
- Enforce workspace, managed environment, provider connection, provider, status, source/observed/verified, and staleness rules.
|
||||
- Decide and test the current no-migration one-row-per-environment/permission-key behavior versus a spec-approved provider-connection-specific migration.
|
||||
- Preserve admin-consent-not-sufficient behavior.
|
||||
- Ensure manual/admin assertions cannot pass unless explicitly verified by repo policy and tested; default fail-closed.
|
||||
|
||||
### Phase 4 - Combined Readiness
|
||||
|
||||
- Add `ExchangePowerShellInvocationReadinessEvaluator` or repo-canonical equivalent if needed.
|
||||
- Require credential readiness and permission evidence readiness.
|
||||
- Return safe pass/blocker states with no raw material.
|
||||
- Ensure `ProviderCapabilityEvaluator` and `ExchangePowerShellProductionRunner` use the same readiness truth or equivalent shared result.
|
||||
|
||||
### Phase 5 - Provider Capability Integration
|
||||
|
||||
- Update provider capability evaluation so `exchange_powershell_invoke` is Supported only when combined readiness passes.
|
||||
- Prove admin consent, client secret, missing credential, missing permission, stale permission, wrong-scope permission, unsupported provider, and unknown evidence do not produce Supported.
|
||||
- Keep capability status values within existing `ProviderCapabilityStatus` unless a new status has a direct behavioral need and proportionality review.
|
||||
- Record the expected rendered impact on existing provider capability/readiness surfaces and keep default output canonical/redacted.
|
||||
|
||||
### Phase 6 - Runner Gate Integration
|
||||
|
||||
- Wire Spec 432 runner gate to combined readiness before runtime/process execution.
|
||||
- Store only safe readiness/blocker state in OperationRun context.
|
||||
- Do not create a new OperationRun type unless explicitly justified; current expectation is reuse `tenant_configuration.exchange_powershell_invocation`.
|
||||
- Preserve disabled default and production-runner config gates from Spec 432.
|
||||
|
||||
### Phase 7 - No-Promotion and Surface Guard
|
||||
|
||||
- Add tests/guard scans proving no tenant configuration evidence rows, raw/normalized Exchange payloads, content-backed/comparable/renderable/certified/restore/customer states, new UI/routes/assets/global search, jobs/schedules/listeners, Exchange-specific tables, legacy shims, fallback readers, or `tenant_id`.
|
||||
- Add product-surface guard proof that existing provider readiness/capability surfaces do not show raw credential, permission, provider, OperationRun, stdout/stderr, or scope-diagnostic payloads by default.
|
||||
|
||||
### Phase 8 - Validation and Report
|
||||
|
||||
- Run focused Spec 433 tests.
|
||||
- Run Spec 432/431/430 and selected no-promotion/provider regressions.
|
||||
- Run migration/PostgreSQL lane if schema changes.
|
||||
- Run focused browser proof and Human Product Sanity for existing provider capability/readiness surfaces.
|
||||
- Run Pint and `git diff --check`.
|
||||
- Create implementation report with matrices for credential kinds, permission evidence, migration/no-migration, no-promotion, validation, deployment impact, Product Surface proof, browser/Human Product Sanity result, and no completed-spec rewrite proof.
|
||||
|
||||
## Rollout and Deployment Considerations
|
||||
|
||||
- No runtime enablement is planned by this spec. Existing production-runner feature flags remain disabled by default.
|
||||
- If a permission-currentness config key is added, default it fail-closed or preserve existing 30-day behavior with explicit tests.
|
||||
- If migration is added, it must be safe for staging validation and must not add secret material, raw payloads, Exchange-specific tables, or `tenant_id`.
|
||||
- No queue, scheduler, storage, asset, browser build, or `filament:assets` deployment impact is expected.
|
||||
- Dokploy staging must validate any migration/config change before production.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Hard stop if client-secret credentials can pass Exchange PowerShell readiness.
|
||||
- Hard stop if admin consent alone makes `exchange_powershell_invoke` Supported.
|
||||
- Hard stop if stale, missing, unvalidated, or wrong-scope permission evidence passes.
|
||||
- Hard stop if credential material or raw permission/provider payloads enter context, logs, tests, or persisted metadata.
|
||||
- Hard stop if Exchange provider calls, PowerShell execution, evidence rows, new UI/routes/jobs/schedules/listeners, customer output, Exchange-specific tables, or `tenant_id` are introduced.
|
||||
- Hard stop if existing rendered provider capability/readiness surfaces are changed without the Product Surface proof, browser proof, and Human Product Sanity required by this plan.
|
||||
@ -0,0 +1,351 @@
|
||||
# Feature Specification: Exchange Credential and Permission Evidence Support
|
||||
|
||||
**Feature Branch**: `433-exchange-credential-permission-evidence-support`
|
||||
**Created**: 2026-07-07
|
||||
**Status**: Implemented
|
||||
**Input**: User-provided Spec 433 draft for Exchange credential and permission evidence support.
|
||||
|
||||
## Preparation Selection
|
||||
|
||||
- **Selected candidate**: Spec 433 - Exchange Credential and Permission Evidence Support.
|
||||
- **Source location**: User-provided attachment `pasted-text.txt`, titled `Spec 433 - Exchange Credential & Permission Evidence Support`.
|
||||
- **Why selected**: `docs/product/spec-candidates.md` states that the automatic next-best-prep queue is empty and manual-promotion only, but the user supplied an explicit P0 draft. Spec 432 is implemented and validated as the production-runner boundary. The next narrow blocker is first-class, scoped, current, redaction-safe credential and permission evidence for that runner gate.
|
||||
- **Roadmap relationship**: Continues the Exchange/Teams Coverage v2 sequence after Specs 429-432. Spec 431's historical "Spec 433" label for content-backed evidence promotion is superseded by the Spec 432 follow-up decision; that promotion is now Spec 434. This spec prepares Spec 434 without itself capturing Exchange configuration evidence.
|
||||
- **Completed-spec guardrail result**: Specs 429-432 are completed implementation context and were not modified. Spec 432 implementation report records PASS and proves the disabled default, production runner gate, runtime readiness checker, credential resolver, permission evaluator, process executor, argument-vector command construction, output guards, timeout/concurrency guards, sanitized OperationRun context, no evidence rows, no UI/routes/jobs/schedules/listeners, no migration, and no `tenant_id`. No existing `specs/433-*` package existed before this preparation.
|
||||
- **Smallest viable implementation slice**: Evidence-readiness support for the existing Spec 432 Exchange PowerShell runner gate and existing provider capability/readiness evaluation. The slice refines or extends existing provider credential, permission evidence, capability, and runner-gate code so TenantPilot can decide whether live invocation is allowed, while still making live invocation safely block when supported credential or verified permission evidence is absent.
|
||||
- **Feature description fed into Spec Kit**: Prepare first-class Exchange PowerShell credential and `Exchange.ManageAsApp` permission evidence support for the existing Spec 432 runner gate, preserving workspace/managed-environment/provider-connection scope, redaction, provider capability accuracy, no evidence promotion, no new routes/pages/navigation/assets/global-search surfaces, no Exchange calls, no PowerShell execution, no customer claims, and no `tenant_id`.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Spec 432 can block or gate Exchange PowerShell production execution, but the credential and permission evidence feeding that gate is still too implicit. Credential accessibility, wrong-scope states, permission evidence source/currentness metadata, and combined readiness truth are not first-class enough for safe live invocation decisions.
|
||||
- **Today's failure**: TenantPilot could overclaim `exchange_powershell_invoke` support from admin consent, stale `Exchange.ManageAsApp` rows, client-secret credentials, wrong-scope details, or incomplete certificate metadata. It could also make the production runner appear ready without durable evidence of credential accessibility and permission currentness.
|
||||
- **User-visible improvement**: Operators and reviewers gain a truthful internal safety claim: Exchange PowerShell live invocation is either backed by current, scoped credential and permission evidence, or it blocks with a precise sanitized reason. No Exchange configuration evidence or customer claim is created.
|
||||
- **Smallest enterprise-capable version**: Use or narrowly extend existing `ProviderCredential`, `ManagedEnvironmentPermission`, provider capability evaluation, and Spec 432 runner gates. Implement explicit states and tests for credential readiness, `Exchange.ManageAsApp` permission evidence, combined readiness, and no-promotion guarantees.
|
||||
- **Explicit non-goals**: No Exchange provider calls, no PowerShell execution, no tenant configuration evidence rows, no raw/normalized Exchange payload, no content-backed/comparable/renderable/certified/restore/customer-ready promotion, no new UI/routes/navigation/Filament/Livewire/assets/global search, no new jobs/schedules/listeners, no Review Pack/report/PDF output, no Teams, no Exchange Admin API, no Exchange-specific credential table, no secret material column, no `tenant_id`, no legacy shim, no fallback reader.
|
||||
- **Permanent complexity imported**: May add a narrow combined readiness evaluator and explicit evidence states/reason mappings. May add minimal provider-agnostic credential or permission metadata only if existing fields cannot safely represent evidence readiness. This imports focused service/test complexity but no customer-facing semantic framework.
|
||||
- **Why now**: Spec 432 intentionally left live invocation blocked until credential and permission evidence can be proven safely. Spec 434 content-backed evidence promotion must not start until this prerequisite is explicit, scoped, current, and auditable.
|
||||
- **Why not local**: Local checks inside the runner or capability evaluator would duplicate readiness truth and increase overclaim risk. The readiness decision must be testable at the provider capability gate and at the Spec 432 runner gate.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: New state/reason vocabulary and possible new metadata. Defense: each state changes execution behavior, provider capability support, auditability, or redaction safety. Metadata is allowed only if existing provider-agnostic models cannot represent current evidence safely.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve as a narrow backend evidence-readiness prerequisite for Exchange PowerShell live invocation gates.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view / environment-bound provider readiness and operation execution boundary.
|
||||
- **Primary Routes**: Existing provider connection and required-permissions/readiness surfaces only; no new route is added.
|
||||
- **Data Ownership**: Existing `ProviderCredential` is scoped through `ProviderConnection`; existing `ManagedEnvironmentPermission` is workspace + managed-environment scoped and must include provider-connection scope in durable metadata or an equivalent provider-agnostic field. `OperationRun` remains execution truth only. No `tenant_id` ownership truth.
|
||||
- **RBAC**: Workspace and managed-environment entitlement remain required before invoking the gate. Non-member or wrong-scope access returns 404. Member missing provider-run capability returns 403 or repo-equivalent. Provider capability support is never authorization by itself.
|
||||
|
||||
For canonical-view specs:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Existing workspace/managed-environment/provider-connection scoping remains unchanged.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Credential, permission, provider capability, and runner-gate checks must require matching `workspace_id`, `managed_environment_id`, and `provider_connection_id` before any readiness pass.
|
||||
|
||||
## No Legacy / No Backward Compatibility Constraint *(mandatory)*
|
||||
|
||||
TenantPilot is pre-production unless this spec explicitly records a compatibility exception.
|
||||
|
||||
- **Compatibility posture**: canonical replacement/addition only.
|
||||
- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no.
|
||||
- **Why clean replacement is safe now**: Exchange PowerShell live invocation is still gated and not a shipped customer capability. Old credential/permission ambiguity should be replaced or blocked rather than carried as compatibility behavior.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [x] Filament panel/provider surface changed
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [ ] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [ ] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [x] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
|
||||
|
||||
This spec adds no new route, page, component, navigation item, action, asset, global-search behavior, credential-management UI, permission-management UI, customer route, report, or Review Pack output. However, it changes the existing provider capability/readiness truth consumed by rendered surfaces, so existing status presentation can change without editing UI files.
|
||||
|
||||
- **Affected existing surfaces**: provider connection capability/readiness presentation, environment required-permissions provider-capability section, and verification-assist provider-capability presentation where those surfaces already render `ProviderCapabilityEvaluator`/registry output.
|
||||
- **Surface intent**: show only canonical supported/not-supported/blocking readiness states and safe high-level blocker labels.
|
||||
- **Forbidden default content**: raw credential material, raw permission `details`, raw provider payloads, OperationRun context, provider-native tenant IDs as ownership truth, stack traces, stdout/stderr, and unredacted diagnostics.
|
||||
- **No new affordance**: no new primary action, repair CTA, destructive action, or customer-visible Exchange evidence claim is introduced.
|
||||
|
||||
## Product Surface Impact *(mandatory for UI-affecting specs; otherwise write `N/A - no rendered product surface changed` plus rationale)*
|
||||
|
||||
Reference: `docs/product/standards/product-surface-contract.md`.
|
||||
|
||||
- **Product Surface Contract applies?**: yes - provider capability/readiness state changes are rendered by existing surfaces.
|
||||
- **Page archetype**: existing provider readiness/capability surfaces.
|
||||
- **Primary user question**: Is this provider connection ready for Exchange PowerShell live invocation?
|
||||
- **Primary action**: none added; existing actions and authorization remain unchanged.
|
||||
- **Surface budget result**: no new surface budget is introduced; changed output must reuse existing status slots and avoid extra panels, tables, or diagnostics.
|
||||
- **Technical Annex / deep-link demotion**: OperationRun remains internal execution truth, not customer proof. Raw evidence, credential details, permission `details`, IDs used only for scope checks, provider diagnostics, and logs stay out of default product views.
|
||||
- **Canonical status vocabulary**: existing `ProviderCapabilityStatus`/provider-readiness status vocabulary must be reused unless a new state has direct behavior consequences and passes proportionality review.
|
||||
- **Visible complexity impact**: neutral-to-reduced; existing surfaces should become more truthful without adding default-visible technical detail.
|
||||
- **Product Surface exceptions**: none.
|
||||
|
||||
## Browser Verification Plan *(mandatory)*
|
||||
|
||||
- **Browser proof required?**: yes, focused read-only smoke after implementation because existing provider capability/readiness surfaces render changed status truth.
|
||||
- **No-browser rationale**: N/A.
|
||||
- **Focused path when required**: existing Provider Connection capability/readiness view and existing Environment Required Permissions / verification-assist provider-capability section for a fixture with not-ready and ready-equivalent states.
|
||||
- **Primary interaction to execute**: open the existing surfaces, confirm no new navigation/action appears, confirm readiness state is canonical and redacted, and confirm wrong-scope/stale/missing evidence does not display as ready.
|
||||
- **Console, Livewire, Filament, network, and 500-error checks**: required for the focused paths.
|
||||
- **Full-suite failure triage**: only failures tied to changed provider capability/readiness surfaces block this spec; unrelated browser debt is documented separately.
|
||||
|
||||
## Human Product Sanity Check *(mandatory)*
|
||||
|
||||
- **Required?**: yes.
|
||||
- **No-human-sanity rationale**: N/A.
|
||||
- **Reviewer questions**: Does the existing surface answer readiness without exposing technical payloads? Is there one clear status? Are raw evidence/OperationRun/provider details demoted? Are no new repair or destructive actions introduced?
|
||||
- **Planned result location**: implementation report / PR close-out.
|
||||
|
||||
## Product Surface Merge Gate Checklist *(mandatory)*
|
||||
|
||||
- [x] No-legacy posture or approved exception recorded.
|
||||
- [x] Product Surface Impact is completed for existing provider capability/readiness surfaces.
|
||||
- [x] Browser proof is planned for existing provider capability/readiness surfaces.
|
||||
- [x] Human Product Sanity is planned for existing provider capability/readiness surfaces.
|
||||
- [x] Product Surface exceptions are documented or `none`.
|
||||
- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, visible complexity outcome, and no completed-spec rewrite assertion.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse
|
||||
|
||||
- **Cross-cutting feature?**: yes.
|
||||
- **Interaction class(es)**: Provider capability/readiness, OperationRun execution truth, credential evidence, permission evidence. Existing rendered provider readiness/capability surfaces can change status output through existing data flow.
|
||||
- **Systems touched**: Existing `ExchangePowerShellCredentialReferenceResolver`, `ExchangePowerShellPermissionEvidenceEvaluator`, `ExchangePowerShellProductionRunner`, `ExchangePowerShellInvocationGate`, `ProviderCapabilityEvaluator`, `ProviderCapabilityRegistry`, `ProviderCredential`, `ManagedEnvironmentPermission`, `ProviderConnection`, and `tenantpilot` config.
|
||||
- **Existing pattern(s) to extend**: Provider credential/reference architecture, managed-environment permission evidence, provider capability evaluation, OperationRun context sanitization, Spec 432 runner gate.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: Provider operation/capability contracts, existing provider readiness/capability render paths, and OperationRun lifecycle. No new UI presenter, renderer, or runtime Product Surface framework.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Existing paths are sufficient for operation truth, runner gating, provider scope, and initial fail-closed behavior. They are insufficient for first-class inaccessible credential state, configurable or justified permission currentness, source/observed/verified metadata, and one combined readiness result consumed consistently by capability and runner gates.
|
||||
- **Allowed deviation and why**: A narrow readiness evaluator or provider-agnostic metadata extension is allowed if it replaces implicit scattered checks with one testable execution gate. Broad provider frameworks, evidence writers, and UI frameworks are forbidden.
|
||||
- **Consistency impact**: `exchange_powershell_invoke` capability and the Spec 432 runner gate must agree on readiness. Both must block client-secret/admin-consent-only/missing/stale/wrong-scope cases.
|
||||
- **Review focus**: Verify no overclaim, no secret material, no raw provider payloads, no evidence promotion, and no parallel Exchange mini-platform.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, backend context and runner gate safety only. No rendered start, notification, or deep-link behavior changes.
|
||||
- **Shared OperationRun UX contract/layer reused**: `OperationRunService`, existing Spec 432 invocation gate context, and existing sanitized failure/summary patterns.
|
||||
- **Delegated start/completion UX behaviors**: N/A - no toast, run link, artifact link, browser event, queued DB notification, or rendered surface.
|
||||
- **Local surface-owned behavior that remains**: none.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: unchanged central lifecycle behavior if a run reaches terminal status.
|
||||
- **Exception required?**: none.
|
||||
|
||||
## Provider Boundary / Platform Core Check
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes.
|
||||
- **Boundary classification**: mixed. Exchange permission and credential requirements are provider-owned; scope, provider capability, OperationRun, credential-reference metadata, and permission-evidence currentness are platform-core safety seams.
|
||||
- **Seams affected**: credential reference evaluation, permission evidence evaluation, provider capability support, runner-gate readiness, OperationRun context, and optional provider-agnostic metadata.
|
||||
- **Neutral platform terms preserved or introduced**: provider connection, credential reference, permission evidence, currentness, observed at, verified at, source, readiness, blocked reason, workspace, managed environment.
|
||||
- **Provider-specific semantics retained and why**: `Exchange.ManageAsApp`, Exchange PowerShell invocation, and related credential kind behavior are retained only within the Exchange runner readiness boundary.
|
||||
- **Why this does not deepen provider coupling accidentally**: No provider-specific table, route, UI component, customer vocabulary, compare semantics, restore semantics, or platform ownership key is introduced. Any new persistence must be provider-agnostic.
|
||||
- **Follow-up path**: document-in-feature. Content-backed evidence, compare/render/certification, restore, Teams, customer output, and broader provider readiness UX remain follow-up specs.
|
||||
|
||||
## UI / Surface Guardrail Impact
|
||||
|
||||
Existing operator-facing provider capability/readiness surfaces can show changed readiness results. Guardrail impact is limited to those existing status slots; no new UI files, routes, navigation, actions, dashboard widgets, customer surfaces, or global search behavior are introduced.
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no for Exchange configuration evidence; possible provider-agnostic evidence metadata only if existing fields cannot represent credential/permission readiness safely.
|
||||
- **New persisted entity/table/artifact?**: no by default. A minimal provider-agnostic migration may be added only after proving existing `ProviderCredential` and `ManagedEnvironmentPermission.details` are insufficient.
|
||||
- **New abstraction?**: possibly one combined readiness evaluator, justified to remove duplicate readiness decisions between provider capability and runner gate.
|
||||
- **New enum/state/reason family?**: yes, bounded credential/permission/readiness blocker states with direct execution consequences.
|
||||
- **New cross-domain UI framework/taxonomy?**: no.
|
||||
- **Current operator problem**: Prevent false Exchange PowerShell readiness and protect live invocation from unsafe credentials, stale/wrong-scope permissions, and redaction failures.
|
||||
- **Existing structure is insufficient because**: Spec 432 has resolvers/evaluators, but current repo evidence shows hard-coded 30-day permission currentness, permission source/observed/verified metadata living implicitly in `details`, no explicit inaccessible credential state, no managed identity enum case, and no single combined readiness result shared by capability and runner gate.
|
||||
- **Narrowest correct implementation**: Refine the existing resolver/evaluator/capability path and add one combined readiness result if needed. Add metadata only provider-agnostically and only when existing fields cannot safely prove the gate.
|
||||
- **Ownership cost**: Additional focused service/test surface and possibly a small provider-agnostic migration that future provider readiness work must respect.
|
||||
- **Alternative intentionally rejected**: Treat admin consent, `last_checked_at`, or provider connection existence as sufficient; create Exchange-specific tables; or fold the logic into UI copy or customer claims.
|
||||
- **Release truth**: Current-release safety prerequisite before Exchange content-backed evidence promotion.
|
||||
|
||||
### Data Model Decision Boundary
|
||||
|
||||
The implementation must begin with a data model assessment:
|
||||
|
||||
- Current `ProviderCredential` has `provider_connection_id`, `type`, `credential_kind`, `source`, encrypted hidden `payload`, `last_rotated_at`, and `expires_at`.
|
||||
- Current `ProviderCredentialKind` includes `client_secret`, `certificate`, and `federated`; managed identity is not currently a first-class enum case.
|
||||
- Current `ManagedEnvironmentPermission` has `workspace_id`, `managed_environment_id`, `permission_key`, `status`, `last_checked_at`, and JSONB `details`; the current table is unique by `managed_environment_id` + `permission_key`, not by `provider_connection_id`.
|
||||
- Current `ExchangePowerShellPermissionEvidenceEvaluator` uses `Exchange.ManageAsApp`, `last_checked_at`, and scoped `details` values, with a hard-coded 30-day threshold.
|
||||
|
||||
Preferred result: no migration, with proof that existing fields and safe `details` metadata are sufficient. In the no-migration path, permission evidence remains one row per managed environment and permission key; readiness may pass only when `details` explicitly matches the evaluated provider connection, workspace, managed environment, and provider, and nonmatching or missing details must fail closed. No fallback-to-latest or fallback-to-admin-consent behavior is allowed.
|
||||
|
||||
Allowed fallback: minimal provider-agnostic metadata migration for credential accessibility/source/observed/verified/currentness or permission source/observed/verified/currentness. If implementation finds that a migration is required, runtime implementation MUST stop before adding schema code and update this `spec.md` plus `plan.md` with the exact fields, ownership semantics, constraints/indexes, rollback posture, and proportionality review. Forbidden: Exchange-specific credential or permission tables, secret material columns, raw provider payload columns, UI state tables, and `tenant_id`.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit tests for credential evidence states, permission evidence currentness/scope/source metadata, combined readiness, provider capability status, and redaction. Feature tests for runner-gate integration, OperationRun context safety, no evidence/no product promotion/no new UI/no trigger/no `tenant_id`. Focused browser proof for existing provider capability/readiness surfaces.
|
||||
- **Validation lane(s)**: fast-feedback and confidence focused Pest lanes; PostgreSQL lane if a migration or JSONB/index behavior is added; focused read-only browser smoke for existing provider capability/readiness surfaces.
|
||||
- **Why this classification and these lanes are sufficient**: The runtime change is evidence-readiness and local gate evaluation with no live provider execution, but provider capability/readiness output is rendered by existing surfaces.
|
||||
- **New or expanded test families**: New Spec 433 unit/feature tests under existing TenantConfiguration/provider capability test locations.
|
||||
- **Fixture / helper cost impact**: Explicit workspace, managed environment, provider connection, credential, permission evidence, capability, and OperationRun fixtures. Helpers must remain explicit/feature-local or opt-in.
|
||||
- **Heavy-family visibility / justification**: Focused browser proof is required only because existing status surfaces render provider capability/readiness output. PostgreSQL validation is required only if schema or JSONB/index behavior changes.
|
||||
- **Special surface test profile**: Existing Provider Connection and Environment Required Permissions / verification-assist provider-capability surfaces only.
|
||||
- **Standard-native relief or required special coverage**: no new rendered surface, but existing rendered readiness status requires focused browser proof and Human Product Sanity.
|
||||
- **Reviewer handoff**: Reviewers must confirm no credential material, no raw permission payload, no provider calls, no PowerShell execution, no evidence rows, no new UI, no jobs/schedules/listeners, no `tenant_id`, and no overclaiming provider capability.
|
||||
- **Budget / baseline / trend impact**: none expected unless migration or broader feature-test setup materially expands runtime.
|
||||
- **Escalation needed**: `reject-or-split` if implementation attempts Exchange capture, PowerShell execution, new UI, route/job/schedule/listener triggers, customer output, or Exchange-specific persistence.
|
||||
- **Active feature PR close-out entry**: Guardrail / credential and permission evidence readiness / existing provider capability/readiness surfaces browser-checked, no new rendered UI surface introduced.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec433 --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec432 --compact`
|
||||
- selected Spec 431, Spec 430, Spec 426/427, Spec 417, Spec 419/420, and provider capability regressions
|
||||
- focused read-only browser proof for existing provider capability/readiness surfaces after implementation
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
- PostgreSQL lane if a migration is added
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Credential Evidence Blocks Unsafe Credentials (Priority: P1)
|
||||
|
||||
As a security reviewer, I need Exchange PowerShell credential evidence to distinguish missing, unsupported, expired, inaccessible, wrong-scope, unvalidated, unknown, and ready states, so unsafe credentials cannot pass the live invocation gate.
|
||||
|
||||
**Why this priority**: Credential proof is the first hard stop before any live Exchange PowerShell invocation.
|
||||
|
||||
**Independent Test**: Unit tests prove `client_secret` blocks, missing credentials block, expired certificates block, inaccessible references block distinctly, unsupported certificate/federated/managed-identity states block unless explicitly supported, and no credential material appears in OperationRun context or loggable envelopes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a provider connection with only a client-secret credential, **When** Exchange PowerShell readiness is evaluated, **Then** readiness blocks with a secret-based credential reason.
|
||||
2. **Given** a certificate credential reference that is expired, inaccessible, wrong-scope, or unsupported, **When** readiness is evaluated, **Then** each case returns a distinct safe blocker state.
|
||||
3. **Given** any credential payload contains secret-like values, **When** readiness is evaluated or OperationRun context is written, **Then** raw material never appears.
|
||||
|
||||
### User Story 2 - Permission Evidence Is Scoped and Current (Priority: P1)
|
||||
|
||||
As a provider readiness reviewer, I need `Exchange.ManageAsApp` evidence to be scoped to the same workspace, managed environment, and provider connection and to be current, so admin consent or stale/wrong-scope rows cannot enable live invocation.
|
||||
|
||||
**Why this priority**: Permission overclaim is the main way a configured provider connection could be mislabeled as invocation-ready.
|
||||
|
||||
**Independent Test**: Unit/feature tests prove admin consent alone blocks, missing/unvalidated/stale evidence blocks, wrong workspace/environment/provider-connection evidence blocks, and current verified scoped evidence can pass the permission gate without creating configuration evidence.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** only `permissions.admin_consent`, **When** `exchange_powershell_invoke` is evaluated, **Then** capability is not Supported.
|
||||
2. **Given** `Exchange.ManageAsApp` evidence older than the currentness threshold, **When** readiness is evaluated, **Then** stale evidence blocks.
|
||||
3. **Given** permission evidence for another provider connection, **When** the runner gate evaluates readiness, **Then** wrong-scope evidence blocks before process execution.
|
||||
|
||||
### User Story 3 - Combined Readiness Drives Capability and Runner Gate (Priority: P1)
|
||||
|
||||
As an implementation reviewer, I need one combined Exchange invocation readiness result used by provider capability and the Spec 432 runner gate, so the platform cannot show Supported while the runner blocks for evidence, or vice versa.
|
||||
|
||||
**Why this priority**: Readiness drift between capability evaluation and runner execution would recreate the false-calm problem this spec exists to prevent.
|
||||
|
||||
**Independent Test**: Tests prove combined readiness requires both credential evidence and permission evidence, returns safe blocker states, and is consumed by both `ProviderCapabilityEvaluator` and `ExchangePowerShellProductionRunner` or their repo-canonical equivalents.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** valid permission evidence but unsupported credential evidence, **When** capability is evaluated, **Then** `exchange_powershell_invoke` is not Supported.
|
||||
2. **Given** supported credential evidence but stale permission evidence, **When** runner readiness is evaluated, **Then** live invocation remains blocked.
|
||||
3. **Given** both evidence paths are current, scoped, and safe, **When** readiness is evaluated, **Then** the result may pass the invocation gate but still does not create Exchange configuration evidence or customer claims.
|
||||
|
||||
### User Story 4 - Evidence Readiness Does Not Promote Product Claims (Priority: P1)
|
||||
|
||||
As a release reviewer, I need credential and permission readiness to remain an internal runner prerequisite, so TenantPilot does not claim Exchange settings were captured, compared, rendered, certified, restorable, or customer-ready.
|
||||
|
||||
**Why this priority**: Credential and permission evidence only permits a later live invocation gate; it is not Exchange configuration evidence.
|
||||
|
||||
**Independent Test**: Feature/guard tests prove no `TenantConfigurationResourceEvidence` rows, no raw/normalized Exchange payload, no content-backed/comparable/renderable/certified/restore/customer state, no new UI/routes/assets/global search, no jobs/schedules/listeners, no migration with `tenant_id`, no Exchange-specific credential table, and no raw details on existing provider readiness surfaces.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a readiness pass, **When** persistence is inspected, **Then** no Exchange configuration evidence row or raw provider payload exists.
|
||||
2. **Given** Spec 433 changed runtime artifacts, **When** guard scans run, **Then** no new routes, UI files, jobs, schedules, listeners, customer output, or global search changes are present.
|
||||
3. **Given** provider capability is Supported, **When** customer/report surfaces are inspected, **Then** no customer-facing Exchange evidence or certification claim exists from this spec.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- **FR-433-001**: The implementation MUST keep Spec 433 evidence-readiness only and MUST NOT call Exchange, run PowerShell, or create Exchange configuration evidence.
|
||||
- **FR-433-002**: Credential readiness MUST be evaluated from existing provider credential/reference architecture unless a minimal provider-agnostic metadata migration is proven necessary.
|
||||
- **FR-433-003**: `client_secret` credentials MUST block Exchange PowerShell live invocation.
|
||||
- **FR-433-004**: Credential evaluation MUST distinguish missing, unsupported, expired, inaccessible, wrong-scope, unvalidated, unknown, and ready states where those states are observable from repo-owned fields or a spec-approved metadata migration; otherwise it MUST collapse the unobservable condition into a safe repo-canonical fail-closed state with tests.
|
||||
- **FR-433-005**: Certificate credentials MUST either be supported through safe reference metadata or explicitly block with tested certificate-specific reasons.
|
||||
- **FR-433-006**: Federated and managed-identity credentials MUST either be supported with explicit repo/deployment proof or explicitly block with tested reasons.
|
||||
- **FR-433-007**: Credential evaluation MUST never expose private keys, certificate passwords, client secrets, tokens, authorization headers, raw vault payloads, raw environment dumps, or raw provider errors.
|
||||
- **FR-433-008**: Permission readiness MUST evaluate `Exchange.ManageAsApp` or a repo-canonical equivalent as explicit evidence separate from admin consent.
|
||||
- **FR-433-009**: Admin consent alone MUST NOT make `exchange_powershell_invoke` Supported and MUST NOT pass the runner gate.
|
||||
- **FR-433-010**: Permission evidence MUST be scoped to the same workspace, managed environment, and provider connection before passing readiness, and MUST NOT pass through fallback-to-latest, fallback-to-first, or admin-consent-only behavior.
|
||||
- **FR-433-011**: Permission evidence MUST block when missing, unvalidated, stale, wrong workspace, wrong environment, wrong provider connection, unsupported provider, revoked/absent, or unknown.
|
||||
- **FR-433-012**: Permission currentness MUST be configurable or the fixed threshold MUST be explicitly justified and tested.
|
||||
- **FR-433-013**: Permission evidence source, observed, captured, verified, auditor/observer where applicable, and evaluator version metadata MUST be represented first-class enough for gate evaluation or documented as a safe existing `details` structure.
|
||||
- **FR-433-014**: A combined Exchange invocation readiness evaluator or repo-canonical equivalent MUST require credential readiness and permission evidence readiness before returning a live-invocation-ready state.
|
||||
- **FR-433-015**: `exchange_powershell_invoke` provider capability MUST be Supported only when combined readiness passes.
|
||||
- **FR-433-016**: The Spec 432 production runner gate MUST consume the combined readiness result or repo-canonical equivalent before process execution.
|
||||
- **FR-433-017**: OperationRun context MAY record only safe readiness and blocker states; it MUST NOT record credential material, raw permission payloads, raw provider payloads, or sensitive exception messages.
|
||||
- **FR-433-018**: The implementation MUST prove no evidence rows, raw Exchange payloads, normalized Exchange payloads, content-backed state, compare/render/certification state, restore state, or customer claim is introduced.
|
||||
- **FR-433-019**: The implementation MUST prove no new route, Filament page, Livewire component, navigation item, global search change, asset, job, schedule, or listener is introduced; existing provider capability/readiness surfaces may only reflect changed evaluator output through existing rendering paths.
|
||||
- **FR-433-020**: Any migration MUST be provider-agnostic, reversible where practical, tested, and must not contain Exchange-specific table names, secret material, raw provider payloads, UI state, or `tenant_id`.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- **NFR-433-001**: All tests MUST be deterministic and offline; no test may require Exchange Online connectivity, Microsoft credentials, PowerShell execution, or installed Exchange modules.
|
||||
- **NFR-433-002**: All new or changed state names MUST have behavior consequences and remain bounded to credential/permission/readiness gates.
|
||||
- **NFR-433-003**: Query and lookup behavior MUST enforce workspace, managed-environment, and provider-connection scope before returning readiness.
|
||||
- **NFR-433-004**: Redaction MUST be tested across OperationRun context, returned readiness envelopes, failure messages, logs where testable, and persisted metadata.
|
||||
- **NFR-433-005**: Implementation MUST remain Sail-first locally and Dokploy-aware for staging/production. Any new env key must default fail-closed and be documented.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Provider connection has no credential row.
|
||||
- Provider credential exists but credential kind is empty, unknown, corrupted, or unsupported.
|
||||
- Provider credential is certificate but expiry/currentness/accessibility metadata is missing.
|
||||
- Provider credential source is present but inaccessible or wrong-scope.
|
||||
- Managed identity appears in stored metadata but is not a first-class enum case.
|
||||
- `Exchange.ManageAsApp` row exists but status is not `granted`.
|
||||
- `Exchange.ManageAsApp` row is stale by threshold.
|
||||
- Permission evidence `details` omit workspace, managed environment, provider, or provider connection.
|
||||
- Permission evidence matches environment but not provider connection.
|
||||
- Admin consent is granted but explicit Exchange permission evidence is missing.
|
||||
- Combined readiness passes but Exchange evidence promotion remains out of scope.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- **SC-433-001**: Focused Spec 433 tests prove all credential blocker/pass states required by this spec.
|
||||
- **SC-433-002**: Focused Spec 433 tests prove all permission blocker/pass states required by this spec.
|
||||
- **SC-433-003**: Provider capability tests prove `exchange_powershell_invoke` cannot become Supported from admin consent, client secret, missing evidence, stale evidence, or wrong-scope evidence.
|
||||
- **SC-433-004**: Runner-gate tests prove combined readiness is required before process execution and safe blockers are stored without secrets.
|
||||
- **SC-433-005**: Guard tests prove no evidence promotion, no product/customer claim, no new UI/trigger surface, no Exchange-specific credential table, no raw details on existing provider readiness surfaces, and no `tenant_id`.
|
||||
- **SC-433-006**: Spec 432, Spec 431, Spec 430, and provider capability regressions continue to pass.
|
||||
|
||||
## Risks
|
||||
|
||||
- Existing `ManagedEnvironmentPermission.details` may be flexible enough but not durable or queryable enough for long-term evidence gate proof.
|
||||
- Adding a metadata migration could become too broad unless provider-agnostic and tightly bounded.
|
||||
- Capability evaluation and runner gate logic could drift if the combined readiness result is not shared.
|
||||
- Supporting certificate/federated/managed-identity pass states prematurely could imply production credential support that deployment cannot satisfy.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 432 PASS remains valid and has no merge-blocking runtime safety findings.
|
||||
- Exchange PowerShell live invocation remains disabled by default and out of scope for this prep pass.
|
||||
- `Exchange.ManageAsApp` is the current target permission key unless implementation finds a repo-canonical equivalent.
|
||||
- Currentness can safely default fail-closed while implementation decides configurable threshold versus justified fixed threshold.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Can existing `ProviderCredential` fields and hidden encrypted payload metadata safely represent credential accessibility/source/verified/currentness without migration?
|
||||
- Can existing `ManagedEnvironmentPermission.details` safely represent permission source/observed/verified/evaluator metadata, or is a provider-agnostic migration needed?
|
||||
- Should Spec 433 make the permission currentness threshold configurable, or explicitly justify and test the existing 30-day fixed threshold?
|
||||
- Can certificate credentials safely pass in this spec, or should they remain explicitly blocked pending deployment credential support?
|
||||
|
||||
These questions do not block implementation only when the implementation takes the safe fail-closed path. If any answer requires new persisted fields, constraints, or first-class credential/permission scope metadata, implementation must stop and update `spec.md` plus `plan.md` before adding schema code.
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- Spec 434 - Exchange Content-Backed Evidence Promotion Slice 1.
|
||||
- Exchange comparable/renderable promotion after content-backed evidence exists.
|
||||
- Teams PowerShell adapter and evidence support.
|
||||
- M365 customer output and claim guard for Exchange/Teams after evidence is real.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
PASS. The selected candidate is directly provided by the user, is not covered by an existing active or completed Spec 433 package, follows implemented Spec 432, aligns with the Exchange/Teams roadmap sequence, and is narrow enough for a bounded implementation loop. Adjacent evidence promotion, new UI, Teams, customer output, compare/render/certification, and restore work are deferred.
|
||||
|
||||
## Spec Readiness Gate
|
||||
|
||||
PASS with implementation conditions. `plan.md`, `tasks.md`, and `checklists/requirements.md` are aligned with this corrected scope. Runtime implementation must not begin until the implementation skill re-checks Spec 432 prerequisites, repo state, the Product Surface proof plan, and the data-model decision. Any migration requirement or new rendered surface beyond existing provider readiness/capability slots requires updating this spec and plan before code changes continue.
|
||||
@ -0,0 +1,186 @@
|
||||
# Tasks: Exchange Credential and Permission Evidence Support
|
||||
|
||||
**Input**: Design documents from `specs/433-exchange-credential-permission-evidence-support/`
|
||||
**Prerequisites**: `spec.md`, `plan.md`, Spec 432 implementation report, current repo source truth
|
||||
**Tests**: Required. Runtime behavior changes must use Pest 4 focused unit/feature tests. Focused browser proof is required for existing provider capability/readiness surfaces because evaluator output is rendered there.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [x] New or changed tests stay in the smallest honest family, and any PostgreSQL/heavy/browser addition is explicit.
|
||||
- [x] Shared helpers, factories, seeds, fixtures, provider setup, permission setup, and OperationRun context defaults stay cheap by default or isolated behind explicit opt-ins.
|
||||
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [x] Browser proof is planned for existing provider capability/readiness surfaces.
|
||||
- [x] Human Product Sanity and Product Surface implementation-report close-out are planned for existing provider capability/readiness surfaces.
|
||||
- [x] Any material budget, baseline, trend, or escalation note is recorded in the implementation report.
|
||||
|
||||
## Phase 1: Preflight and Repo Truth
|
||||
|
||||
**Purpose**: Confirm Spec 433 starts from the implemented Spec 432 boundary and does not rewrite completed specs.
|
||||
|
||||
- [x] T001 Capture branch, HEAD, and `git status --short`.
|
||||
- [x] T002 Confirm Specs 429-432 are completed read-only context and are not edited.
|
||||
- [x] T003 Confirm Spec 432 implementation report is PASS and has no open merge-blocking runtime safety findings.
|
||||
- [x] T004 Inspect `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCredentialReferenceResolver.php`.
|
||||
- [x] T005 Inspect `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellPermissionEvidenceEvaluator.php`.
|
||||
- [x] T006 Inspect `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProductionRunner.php` and `ExchangePowerShellInvocationGate.php`.
|
||||
- [x] T007 Inspect `apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php` and `ProviderCapabilityRegistry.php`.
|
||||
- [x] T008 Inspect `apps/platform/app/Models/ProviderCredential.php`, `ProviderCredentialKind.php`, `ManagedEnvironmentPermission.php`, and related migrations.
|
||||
- [x] T009 Confirm no Exchange calls, no PowerShell execution, no evidence promotion, no new UI/routes/jobs/schedules/listeners, no customer output, and no `tenant_id` are planned; identify existing provider capability/readiness surfaces that will need browser proof.
|
||||
|
||||
**Checkpoint**: Preflight confirms only Spec 433 implementation work is in scope.
|
||||
|
||||
## Phase 2: Data Model Decision
|
||||
|
||||
**Purpose**: Decide whether existing provider-agnostic fields are sufficient before any schema work.
|
||||
|
||||
- [x] T010 Document whether `ProviderCredential` fields can safely represent credential kind, source, accessibility, currentness, expiry, and verified/observed metadata without migration, and identify which credential states are repo-observable versus fail-closed equivalents.
|
||||
- [x] T011 Document whether `ManagedEnvironmentPermission` plus `details` can safely represent `Exchange.ManageAsApp` source, observed, verified, provider connection scope, currentness, and evaluator metadata without migration, including the current `managed_environment_id` + `permission_key` uniqueness and provider-connection matching through `details`.
|
||||
- [x] T012 If no migration is needed, add or update tests proving the existing fields/details are sufficient for all gate states.
|
||||
- [x] T013 If migration is needed, stop implementation and update `spec.md` plus `plan.md` with exact fields, ownership semantics, constraints/indexes, rollback posture, and proportionality review before adding schema code. N/A - no migration was needed.
|
||||
- [x] T014 After an approved migration amendment, add the narrowest provider-agnostic migration and PostgreSQL/schema tests proving no Exchange-specific table, no secret material column, no raw provider payload column, no UI state column, no `tenant_id`, and reversible/drop-safe behavior where practical. N/A - no migration was added.
|
||||
- [x] T015 Record the data-model decision, no-migration or migration proof, and proportionality review in the implementation report.
|
||||
|
||||
**Checkpoint**: Data model path is explicit and provider-agnostic.
|
||||
|
||||
## Phase 3: Credential Evidence States
|
||||
|
||||
**Purpose**: Make credential readiness explicit, scoped, and redaction-safe.
|
||||
|
||||
- [x] T016 Extend or refactor `ExchangePowerShellCredentialReferenceResolver` to return repo-canonical states for ready, missing, unsupported, expired, inaccessible, wrong-scope, unvalidated, and unknown credentials where repo-owned fields or an approved metadata migration make those states observable; otherwise return safe fail-closed equivalents.
|
||||
- [x] T017 Keep `client_secret` blocked for Exchange PowerShell live invocation.
|
||||
- [x] T018 Add or confirm certificate credential handling for missing metadata, expired reference, inaccessible reference, wrong scope, unsupported reference, unvalidated reference, and supported-safe reference when explicitly enabled; unobservable states must collapse to tested safe blockers.
|
||||
- [x] T019 Add or confirm federated credential handling for missing, unsupported, wrong scope, inaccessible, unvalidated, and supported-safe states when repo/deployment support exists; unobservable states must collapse to tested safe blockers.
|
||||
- [x] T020 Add or confirm managed identity handling; if not first-class in `ProviderCredentialKind`, keep it explicitly blocked or add the enum only with proportionality and tests.
|
||||
- [x] T021 Ensure credential state context contains only safe metadata such as kind label, state, timestamps, IDs allowed by repo policy, and redaction markers.
|
||||
- [x] T022 Add unit tests for missing, client-secret, certificate expired, certificate unsupported, repo-observable certificate inaccessible/wrong-scope or their safe fail-closed equivalents, federated unsupported, managed identity unsupported, unknown kind, and supported-safe credential paths.
|
||||
- [x] T023 Add redaction tests proving no private key, certificate password, client secret, token, authorization header, raw vault secret, raw credential payload, or sensitive exception reaches OperationRun context, logs, or returned readiness envelopes.
|
||||
|
||||
**Checkpoint**: Credential evidence is explicit and cannot leak credential material.
|
||||
|
||||
## Phase 4: Permission Evidence States
|
||||
|
||||
**Purpose**: Make `Exchange.ManageAsApp` evidence current, scoped, and auditable.
|
||||
|
||||
- [x] T024 Extend or refactor `ExchangePowerShellPermissionEvidenceEvaluator` to use `Exchange.ManageAsApp` or repo-canonical equivalent as explicit permission evidence.
|
||||
- [x] T025 Preserve admin-consent-not-sufficient behavior.
|
||||
- [x] T026 Enforce workspace, managed environment, provider, and provider connection scope for permission evidence with no fallback-to-latest, fallback-to-first, or admin-consent-only pass behavior.
|
||||
- [x] T027 Enforce missing, unvalidated, stale, wrong workspace, wrong environment, wrong provider connection, unsupported provider, revoked/absent, and unknown blocker states.
|
||||
- [x] T028 Make permission currentness threshold configurable or explicitly justify and test the existing fixed 30-day threshold.
|
||||
- [x] T029 Add or prove source, observed, captured, verified, auditor/observer where applicable, and evaluator version metadata in first-class fields or safe `details`.
|
||||
- [x] T030 Add unit tests for missing, unvalidated, stale, wrong workspace, wrong environment, wrong provider connection, unsupported provider, admin-consent-only, verified current scoped evidence, and the current one-row-per-environment/permission-key no-migration behavior.
|
||||
- [x] T031 Add feature tests proving wrong-scope and stale permission evidence block before process execution and do not create evidence rows.
|
||||
|
||||
**Checkpoint**: Permission evidence is scoped, current, and separate from admin consent.
|
||||
|
||||
## Phase 5: Combined Readiness
|
||||
|
||||
**Purpose**: Keep provider capability and runner gate aligned on one evidence truth.
|
||||
|
||||
- [x] T032 Add `ExchangePowerShellInvocationReadinessEvaluator` or repo-canonical equivalent if existing paths cannot share one readiness result.
|
||||
- [x] T033 Ensure combined readiness requires both credential readiness and permission evidence readiness.
|
||||
- [x] T034 Ensure combined readiness returns safe `ready_for_live_invocation` or blocked states without raw credential, permission, provider, stdout, stderr, or exception payloads.
|
||||
- [x] T035 Add tests proving valid permission plus unsupported credential blocks.
|
||||
- [x] T036 Add tests proving supported credential plus missing/stale/wrong-scope permission blocks.
|
||||
- [x] T037 Add tests proving current scoped credential and permission evidence may pass readiness without creating Exchange configuration evidence or customer claims.
|
||||
|
||||
**Checkpoint**: Combined readiness is the single source consumed by later capability and runner tasks.
|
||||
|
||||
## Phase 6: Provider Capability Integration
|
||||
|
||||
**Purpose**: Prevent `exchange_powershell_invoke` overclaiming support.
|
||||
|
||||
- [x] T038 Update `ProviderCapabilityEvaluator` so `exchange_powershell_invoke` is Supported only when combined readiness passes.
|
||||
- [x] T039 Keep capability status values within existing `ProviderCapabilityStatus` unless a new status is explicitly justified by behavior, Product Surface impact, proportionality review, and tests.
|
||||
- [x] T040 Add tests proving admin consent alone does not produce Supported.
|
||||
- [x] T041 Add tests proving client-secret credentials do not produce Supported.
|
||||
- [x] T042 Add tests proving missing credential, unsupported credential, missing permission, stale permission, wrong-scope permission, unsupported provider, and unknown evidence do not produce Supported.
|
||||
- [x] T043 Add or update provider capability registry tests only if registry definitions change, and record the expected rendered impact on existing provider capability/readiness surfaces.
|
||||
|
||||
**Checkpoint**: Provider capability support matches credential and permission evidence truth.
|
||||
|
||||
## Phase 7: Spec 432 Runner Gate Integration
|
||||
|
||||
**Purpose**: Make the production runner consume first-class readiness before any process execution.
|
||||
|
||||
- [x] T044 Wire `ExchangePowerShellProductionRunner` or repo-canonical runner gate to combined readiness before runtime/process execution.
|
||||
- [x] T045 Preserve disabled default and explicit invocation + production-runner config gates from Spec 432.
|
||||
- [x] T046 Store only safe readiness/blocker state in OperationRun context.
|
||||
- [x] T047 Do not add a new OperationRun type unless implementation finds an unavoidable reason and updates spec/plan before continuing.
|
||||
- [x] T048 Add feature tests proving runner blocks before process execution when credential or permission readiness fails.
|
||||
- [x] T049 Add feature tests proving OperationRun context includes only safe state and no raw credential material, raw permission payload, raw provider payload, raw stdout/stderr, or sensitive exception detail.
|
||||
|
||||
**Checkpoint**: Runner gate consumes evidence readiness safely and remains no-promotion.
|
||||
|
||||
## Phase 8: No-Promotion and Surface Guards
|
||||
|
||||
**Purpose**: Prove credential/permission evidence does not become Exchange configuration evidence or a new product surface, and that existing readiness surfaces stay canonical and redacted.
|
||||
|
||||
- [x] T050 Add tests proving no `TenantConfigurationResourceEvidence` or repo-equivalent evidence rows are created.
|
||||
- [x] T051 Add tests proving no raw Exchange payload, normalized Exchange payload, content-backed state, comparable state, renderable state, certified state, restore-ready state, customer-ready state, report output, Review Pack output, or PDF output is introduced.
|
||||
- [x] T052 Add guard scans/tests proving no new route, Filament page, Livewire component, navigation item, asset, global search change, dashboard/readiness badge, customer route, credential management UI, or permission management UI is introduced.
|
||||
- [x] T053 Add guard scans/tests proving no job, schedule, listener, or console trigger can invoke Exchange PowerShell from this spec.
|
||||
- [x] T054 Add guard scans/tests proving no Exchange-specific credential table, Exchange-specific permission table, legacy shim, fallback reader, dual write, or `tenant_id` ownership path is introduced, and existing provider readiness/capability surfaces do not default-render raw credential, permission, provider, OperationRun, stdout/stderr, or scope-diagnostic payloads.
|
||||
|
||||
**Checkpoint**: No evidence, product claim, UI, trigger surface, or ownership drift exists.
|
||||
|
||||
## Phase 9: Regression and Validation
|
||||
|
||||
**Purpose**: Prove Spec 433 did not regress the Exchange/Teams sequence or provider readiness behavior.
|
||||
|
||||
- [x] T055 Run focused Spec 433 unit and feature tests.
|
||||
- [x] T056 Run Spec 432 production runner boundary regressions.
|
||||
- [x] T057 Run Spec 431 invocation gate/security regressions.
|
||||
- [x] T058 Run Spec 430 adapter contract regressions.
|
||||
- [x] T059 Run selected Spec 426 and Spec 427 no-promotion/source-contract regressions where available.
|
||||
- [x] T060 Run selected Spec 417 identity, Spec 419 registry, and Spec 420 generic evidence regressions where available.
|
||||
- [x] T061 Run provider capability registry/evaluator regressions.
|
||||
- [x] T062 If migration is added, run PostgreSQL/schema validation lane. N/A - no migration was added; Sail migration check reported nothing to migrate.
|
||||
- [x] T063 Run focused read-only browser proof for existing provider capability/readiness surfaces.
|
||||
- [x] T064 Complete Human Product Sanity for existing provider capability/readiness surfaces.
|
||||
- [x] T065 Run Pint for dirty/new files.
|
||||
- [x] T066 Run `git diff --check`.
|
||||
- [x] T067 Capture final `git status --short`.
|
||||
|
||||
**Checkpoint**: Focused tests, regressions, formatting, and diff checks are documented.
|
||||
|
||||
## Phase 10: Implementation Report
|
||||
|
||||
**Purpose**: Close out the implementation with evidence, matrices, and residual risks.
|
||||
|
||||
- [x] T068 Create `specs/433-exchange-credential-permission-evidence-support/implementation-report.md`.
|
||||
- [x] T069 Record candidate gate result, branch/HEAD, dirty state before/after, files changed, and activated skills/gates.
|
||||
- [x] T070 Record Spec 432 prerequisite proof and completed-spec read-only proof.
|
||||
- [x] T071 Record data-model decision, migration/no-migration proof, and proportionality review.
|
||||
- [x] T072 Record credential evidence model proof, credential kind matrix, repo-observable versus fail-closed state proof, wrong-scope proof where observable, and credential redaction proof.
|
||||
- [x] T073 Record permission evidence proof, `Exchange.ManageAsApp` proof, admin-consent-not-sufficient proof, currentness proof, metadata proof, wrong-scope proof, one-row-per-environment/permission-key decision, and no-fallback proof.
|
||||
- [x] T074 Record combined readiness proof, provider capability proof, runner gate integration proof, and expected existing surface impact.
|
||||
- [x] T075 Record OperationRun context safety, no evidence, no compare/render/certification, no restore/customer claim, no new UI/route/job/schedule/listener, no `tenant_id`, no raw details on existing provider readiness/capability surfaces, and no mini-platform proof.
|
||||
- [x] T076 Record Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, browser proof result, Human Product Sanity result, deployment impact, visible complexity outcome, no completed-spec rewrite assertion, tests run, deferred work, and follow-up candidates.
|
||||
- [x] T077 Include credential matrix, permission matrix, migration/no-migration matrix, product-surface/browser matrix, and no-promotion matrix.
|
||||
|
||||
**Checkpoint**: Implementation report is complete and reviewable.
|
||||
|
||||
## Explicit Non-Goals During Implementation
|
||||
|
||||
- [x] No Exchange provider calls.
|
||||
- [x] No PowerShell execution.
|
||||
- [x] No evidence persistence or promotion.
|
||||
- [x] No content-backed, comparable, renderable, certified, restore-ready, or customer-ready state.
|
||||
- [x] No new UI/routes/navigation/global search/assets; existing provider capability/readiness surfaces may only reflect changed evaluator output through existing canonical/redacted rendering.
|
||||
- [x] No jobs/schedules/listeners that invoke the runner.
|
||||
- [x] No customer reports, Review Pack output, or PDF output.
|
||||
- [x] No Teams, Exchange Admin API, broader Exchange target types, or customer claims.
|
||||
- [x] No Exchange-specific credential or permission table.
|
||||
- [x] No secret material columns.
|
||||
- [x] No `tenant_id` ownership truth.
|
||||
- [x] No Exchange mini-platform, legacy shim, fallback reader, or dual write.
|
||||
|
||||
## Dependencies and Execution Order
|
||||
|
||||
- Phase 1 preflight blocks all implementation.
|
||||
- Phase 2 data-model decision blocks migration or metadata changes.
|
||||
- Credential and permission evidence phases block combined readiness.
|
||||
- Combined readiness blocks provider capability and runner gate integration.
|
||||
- No-promotion and surface guards must pass before validation and report close-out.
|
||||
- Focused browser proof and Human Product Sanity must run after provider capability integration and before implementation report close-out.
|
||||
Loading…
Reference in New Issue
Block a user