feat/066-rbac-ui-enforcement-helper-v2 #83
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
@ -21,7 +22,10 @@ coverage/
|
|||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/storage/framework
|
||||||
|
/storage/logs
|
||||||
/vendor
|
/vendor
|
||||||
|
/bootstrap/cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.4.0 → 1.5.0
|
- Version change: 1.5.0 → 1.6.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Tenant Isolation is Non-negotiable (added deny-as-not-found requirement)
|
- Tenant Isolation is Non-negotiable (clarified 404 vs 403 semantics)
|
||||||
|
- RBAC guidance consolidated (RBAC model rules merged into RBAC-UX)
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- RBAC Standard (RBAC-001..RBAC-009)
|
- RBAC & UI Enforcement Standards (RBAC-UX)
|
||||||
- Removed sections: None
|
- Removed sections: None (RBAC-001..009 content consolidated into RBAC-UX)
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/spec-template.md
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md
|
||||||
- N/A: .specify/templates/commands/ (directory not present)
|
- N/A: .specify/templates/commands/ (directory not present in this repo)
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs: None
|
||||||
-->
|
-->
|
||||||
|
|
||||||
@ -43,58 +44,72 @@ ### Tenant Isolation is Non-negotiable
|
|||||||
- Every read/write MUST be tenant-scoped.
|
- Every read/write MUST be tenant-scoped.
|
||||||
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
||||||
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
||||||
- A non-member attempting to access a tenant route MUST be deny-as-not-found (404).
|
- Tenant membership is an isolation boundary. If the actor is not entitled to the tenant scope, the system MUST respond as
|
||||||
|
deny-as-not-found (404).
|
||||||
|
|
||||||
### RBAC Standard (RBAC-001)
|
### RBAC & UI Enforcement Standards (RBAC-UX)
|
||||||
|
|
||||||
RBAC-001 Two Planes
|
RBAC Context — Planes, Roles, and Auditability
|
||||||
- The platform MUST maintain two strictly separated authorization planes:
|
- The platform MUST maintain two strictly separated authorization planes:
|
||||||
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
|
- Tenant plane (`/admin/t/{tenant}`): authenticated Entra users (`users`), authorization is tenant-scoped.
|
||||||
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
|
- Platform plane (`/system`): authenticated platform users (`platform_users`), authorization is platform-scoped.
|
||||||
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
|
- Cross-plane access MUST be deny-as-not-found (404) (not 403) to avoid route enumeration.
|
||||||
|
- Tenant role semantics MUST remain least-privilege:
|
||||||
RBAC-002 Capabilities-first Authorization
|
|
||||||
- Feature code MUST NOT check raw roles directly (e.g. string role comparisons).
|
|
||||||
- Feature code MUST check capabilities via Gates/Policies only.
|
|
||||||
- A canonical capability registry MUST exist as the single source of truth (e.g. `TenantCapabilities` / `PlatformCapabilities`).
|
|
||||||
- Role → capability mapping MUST reference only registry entries.
|
|
||||||
|
|
||||||
RBAC-003 Least Privilege Role Semantics
|
|
||||||
- Tenant roles MUST follow least-privilege semantics:
|
|
||||||
- Readonly: view-only; MUST NOT start operations and MUST NOT mutate data.
|
- Readonly: view-only; MUST NOT start operations and MUST NOT mutate data.
|
||||||
- Operator: MAY start allowed tenant operations; MUST NOT manage credentials, settings, members, or perform destructive actions.
|
- Operator: MAY start allowed tenant operations; MUST NOT manage credentials, settings, members, or perform destructive actions.
|
||||||
- Manager: MAY manage tenant configuration and start operations; MUST NOT manage tenant memberships (Owner-only).
|
- Manager: MAY manage tenant configuration and start operations; MUST NOT manage tenant memberships (Owner-only).
|
||||||
- Owner: MAY manage memberships and all tenant configuration; Owner-only “danger zone” actions MUST remain Owner-only.
|
- Owner: MAY manage memberships and all tenant configuration; Owner-only “danger zone” actions MUST remain Owner-only.
|
||||||
|
|
||||||
RBAC-004 UI is not Security
|
|
||||||
- Hiding UI elements is NOT sufficient.
|
|
||||||
- Every mutation endpoint and action MUST enforce authorization server-side (Policy/Gate).
|
|
||||||
|
|
||||||
RBAC-005 Destructive Actions Gate
|
|
||||||
- All destructive actions (delete / force delete / irreversible operations) MUST:
|
|
||||||
- require an explicit confirmation (e.g., `requiresConfirmation()` or equivalent),
|
|
||||||
- be protected by a Policy/Gate,
|
|
||||||
- have at least one regression test asserting the action is forbidden for non-authorized roles.
|
|
||||||
|
|
||||||
RBAC-006 Membership Safety Rule
|
|
||||||
- The system MUST prevent removing or demoting the last remaining Owner of a tenant.
|
- The system MUST prevent removing or demoting the last remaining Owner of a tenant.
|
||||||
|
- All access-control relevant changes MUST write `AuditLog` entries with stable action IDs, and MUST be redacted (no secrets).
|
||||||
|
|
||||||
RBAC-007 Tenant Isolation
|
RBAC-UX-001 — Server-side is the source of truth
|
||||||
- All tenant-plane queries MUST be tenant-scoped.
|
- UI visibility / disabled state is never a security boundary.
|
||||||
- A non-member attempting to access a tenant route MUST be deny-as-not-found (404).
|
- Every mutating action (create/update/delete/restore/archive/force-delete), every operation start, and every credential/
|
||||||
|
config change MUST enforce authorization server-side via `Gate::authorize(...)` or a Policy method.
|
||||||
|
- Any missing server-side authorization is a P0 security bug.
|
||||||
|
|
||||||
RBAC-008 Auditing
|
RBAC-UX-002 — Deny-as-not-found for non-members
|
||||||
- All access-control relevant changes MUST write `AuditLog` entries with stable action IDs, including:
|
- Tenant membership (and plane membership) is an isolation boundary.
|
||||||
- membership add / role change / remove
|
- If the current actor is not a member of the current tenant (or otherwise not entitled to the tenant scope), the system MUST
|
||||||
- provider credential rotation / connection disable
|
respond as 404 (deny-as-not-found) for tenant-scoped routes/actions/resources.
|
||||||
- break-glass enter / exit / expire (platform plane)
|
- This applies to Filament resources/pages under tenant routing (`/admin/t/{tenant}/...`), Global Search results, and all
|
||||||
- `AuditLog` entries MUST be redacted (no secrets/tokens, minimal identity fields).
|
action endpoints (Livewire calls included).
|
||||||
|
|
||||||
RBAC-009 Testability Gate
|
RBAC-UX-003 — Capability denial is 403 (after membership is established)
|
||||||
- Any new feature that introduces or changes authorization MUST include:
|
- Within an established tenant scope, missing permissions are authorization failures.
|
||||||
- at least one positive test (authorized user can do it),
|
- If the actor is a tenant member, but lacks the required capability for an action, the server MUST fail with 403.
|
||||||
- at least one negative test (unauthorized user cannot do it),
|
- The UI may render disabled actions, but the server MUST still enforce 403 on execution.
|
||||||
- and MUST NOT introduce role-string checks outside the central mapping/registry.
|
|
||||||
|
RBAC-UX-004 — Visible vs disabled UX rule
|
||||||
|
- For tenant members: actions SHOULD be visible but disabled when capability is missing.
|
||||||
|
- Disabled actions MUST provide helper text explaining the missing permission.
|
||||||
|
- For non-members: actions MUST behave as not found (404) and SHOULD NOT leak resource existence.
|
||||||
|
- Exception: highly sensitive controls (e.g., credential rotation) MAY be hidden even for members without permission.
|
||||||
|
|
||||||
|
RBAC-UX-005 — Destructive confirmation standard
|
||||||
|
- All destructive-like actions MUST require confirmation.
|
||||||
|
- Delete/force-delete/archive/restore/remove membership/role downgrade/credential rotation/break-glass enter/exit MUST use
|
||||||
|
`->requiresConfirmation()` and SHOULD include clear warning text.
|
||||||
|
- Confirmation is UX only; authorization still MUST be server-side.
|
||||||
|
|
||||||
|
RBAC-UX-006 — Capability registry is canonical
|
||||||
|
- Capabilities MUST be centrally defined in a single canonical registry (constants/enum).
|
||||||
|
- Feature code MUST reference capabilities only via the registry (no raw string literals).
|
||||||
|
- Role → capability mapping MUST reference only registry entries.
|
||||||
|
- CI MUST fail if unknown/unregistered capabilities are used.
|
||||||
|
|
||||||
|
RBAC-UX-007 — Global search must be tenant-safe
|
||||||
|
- Global search results MUST be scoped to the current tenant.
|
||||||
|
- Non-members MUST never learn about resources in other tenants (no results, no hints).
|
||||||
|
- If a result exists but is not accessible, it MUST be treated as not found (404 semantics).
|
||||||
|
|
||||||
|
RBAC-UX-008 — Regression guards are mandatory
|
||||||
|
- The repo MUST include RBAC regression tests asserting at least:
|
||||||
|
- Readonly cannot mutate or start operations.
|
||||||
|
- Operator can run allowed operations but cannot manage configuration.
|
||||||
|
- Manager/Owner behave according to the role matrix.
|
||||||
|
- The repo SHOULD include an automated “no ad-hoc authorization” guard that blocks new status/permission mappings sprinkled
|
||||||
|
across `app/Filament/**`, pushing patterns into central helpers.
|
||||||
|
|
||||||
### Operations / Run Observability Standard
|
### Operations / Run Observability Standard
|
||||||
- Every long-running or operationally relevant action MUST be observable, deduplicated, and auditable via Monitoring → Operations.
|
- Every long-running or operationally relevant action MUST be observable, deduplicated, and auditable via Monitoring → Operations.
|
||||||
@ -159,4 +174,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.5.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-27
|
**Version**: 1.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-28
|
||||||
|
|||||||
@ -35,7 +35,9 @@ ## Constitution Check
|
|||||||
- Read/write separation: any writes require preview + confirmation + audit + tests
|
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||||
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||||
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||||
- RBAC Standard: two planes (/admin vs /system) remain separated; cross-plane is 404; authorization checks use Gates/Policies + capability registries (no role-string checks)
|
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||||
|
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||||
|
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
|
|||||||
@ -82,12 +82,17 @@ ## Requirements *(mandatory)*
|
|||||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
**Constitution alignment (RBAC Standard):** If this feature introduces or changes authorization behavior, the spec MUST:
|
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||||
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||||
- ensure any cross-plane access is deny-as-not-found (404),
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
- describe how authorization is enforced server-side (Gates/Policies),
|
- explicitly define 404 vs 403 semantics:
|
||||||
- reference the canonical capability registry (no role-string checks in feature code),
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
- include at least one positive and one negative authorization test.
|
- member but missing capability → 403
|
||||||
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||||
|
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||||
|
|
||||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||||
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||||
|
|||||||
@ -16,7 +16,12 @@ # Tasks: [FEATURE NAME]
|
|||||||
without an `OperationRun`.
|
without an `OperationRun`.
|
||||||
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||||
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||||
- capability registry usage (no role-string checks in feature code),
|
- explicit 404 vs 403 semantics:
|
||||||
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
|
- member but missing capability → 403,
|
||||||
|
- capability registry usage (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- tenant-safe global search scoping (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- destructive-like actions use `->requiresConfirmation()` (authorization still server-side),
|
||||||
- cross-plane deny-as-not-found (404) checks where applicable,
|
- cross-plane deny-as-not-found (404) checks where applicable,
|
||||||
- at least one positive + one negative authorization test.
|
- at least one positive + one negative authorization test.
|
||||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Drift\DriftRunSelector;
|
use App\Services\Drift\DriftRunSelector;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
@ -21,7 +22,6 @@
|
|||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class DriftLanding extends Page
|
class DriftLanding extends Page
|
||||||
@ -175,7 +175,10 @@ public function mount(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
|
||||||
$this->state = 'blocked';
|
$this->state = 'blocked';
|
||||||
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
|
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
|
||||||
|
|
||||||
|
|||||||
@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class RegisterTenant extends BaseRegisterTenant
|
class RegisterTenant extends BaseRegisterTenant
|
||||||
{
|
{
|
||||||
@ -33,8 +33,11 @@ public static function canView(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||||
if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) {
|
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,7 +91,9 @@ public function form(Schema $schema): Schema
|
|||||||
*/
|
*/
|
||||||
protected function handleRegistration(array $data): Model
|
protected function handleRegistration(array $data): Model
|
||||||
{
|
{
|
||||||
abort_unless(static::canView(), 403);
|
if (! static::canView()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::create($data);
|
$tenant = Tenant::create($data);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -10,21 +10,23 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -47,7 +49,18 @@ class BackupSetResource extends Resource
|
|||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
return UiEnforcement::for(Capabilities::TENANT_SYNC)->isAllowed();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -89,343 +102,356 @@ public static function table(Table $table): Table
|
|||||||
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
UiEnforcement::forAction(
|
||||||
->andVisibleWhen(fn (?BackupSet $record): bool => $record?->trashed() ?? false)
|
Actions\Action::make('restore')
|
||||||
->apply(
|
->label('Restore')
|
||||||
Actions\Action::make('restore')
|
->color('success')
|
||||||
->label('Restore')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->requiresConfirmation()
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||||
->requiresConfirmation()
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
$tenant = Filament::getTenant();
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
$record->items()->withTrashed()->restore();
|
$record->items()->withTrashed()->restore();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
action: 'backup.restored',
|
action: 'backup.restored',
|
||||||
resourceType: 'backup_set',
|
resourceType: 'backup_set',
|
||||||
resourceId: (string) $record->id,
|
resourceId: (string) $record->id,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
context: ['metadata' => ['name' => $record->name]]
|
context: ['metadata' => ['name' => $record->name]]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup set restored')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('archive')
|
||||||
|
->label('Archive')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||||
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
if ($record->tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup.deleted',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['name' => $record->name]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Backup set archived')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('forceDelete')
|
||||||
|
->label('Force delete')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-trash')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||||
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Backup set restored')
|
->title('Cannot force delete backup set')
|
||||||
->success()
|
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||||
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
return;
|
||||||
->andVisibleWhen(fn (?BackupSet $record): bool => $record ? ! $record->trashed() : false)
|
}
|
||||||
->apply(
|
|
||||||
Actions\Action::make('archive')
|
|
||||||
->label('Archive')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$record->delete();
|
if ($record->tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $record->tenant,
|
||||||
|
action: 'backup.force_deleted',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $record->id,
|
||||||
|
status: 'success',
|
||||||
|
context: ['metadata' => ['name' => $record->name]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($record->tenant) {
|
$record->items()->withTrashed()->forceDelete();
|
||||||
$auditLogger->log(
|
$record->forceDelete();
|
||||||
tenant: $record->tenant,
|
|
||||||
action: 'backup.deleted',
|
|
||||||
resourceType: 'backup_set',
|
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['name' => $record->name]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Backup set archived')
|
->title('Backup set permanently deleted')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
})
|
||||||
),
|
)
|
||||||
|
->preserveVisibility()
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->andVisibleWhen(fn (?BackupSet $record): bool => $record?->trashed() ?? false)
|
->apply(),
|
||||||
->apply(
|
|
||||||
Actions\Action::make('forceDelete')
|
|
||||||
->label('Force delete')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort();
|
|
||||||
|
|
||||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Cannot force delete backup set')
|
|
||||||
->body('Backup sets referenced by restore runs cannot be removed.')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record->tenant,
|
|
||||||
action: 'backup.force_deleted',
|
|
||||||
resourceType: 'backup_set',
|
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['name' => $record->name]]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->items()->withTrashed()->forceDelete();
|
|
||||||
$record->forceDelete();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Backup set permanently deleted')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
UiEnforcement::forBulkAction(
|
||||||
->andHiddenWhen(function (HasTable $livewire): bool {
|
BulkAction::make('bulk_delete')
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
->label('Archive Backup Sets')
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
return $isOnlyTrashed;
|
return $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->apply(
|
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
|
||||||
BulkAction::make('bulk_delete')
|
->form(function (Collection $records) {
|
||||||
->label('Archive Backup Sets')
|
if ($records->count() >= 10) {
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
return [
|
||||||
->color('danger')
|
Forms\Components\TextInput::make('confirmation')
|
||||||
->requiresConfirmation()
|
->label('Type DELETE to confirm')
|
||||||
->modalDescription('This archives backup sets (soft delete). Already archived backup sets will be skipped.')
|
->required()
|
||||||
->form(function (Collection $records) {
|
->in(['DELETE'])
|
||||||
if ($records->count() >= 10) {
|
->validationMessages([
|
||||||
return [
|
'in' => 'Please type DELETE to confirm.',
|
||||||
Forms\Components\TextInput::make('confirmation')
|
]),
|
||||||
->label('Type DELETE to confirm')
|
];
|
||||||
->required()
|
}
|
||||||
->in(['DELETE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type DELETE to confirm.',
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
if (! $tenant instanceof Tenant) {
|
||||||
$user = auth()->user();
|
return;
|
||||||
$count = $records->count();
|
}
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_set.delete',
|
type: 'backup_set.delete',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
BulkBackupSetDeleteJob::dispatch(
|
BulkBackupSetDeleteJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) ($initiator?->getKey() ?? 0),
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
backupSetIds: $ids,
|
backupSetIds: $ids,
|
||||||
operationRun: $operationRun,
|
operationRun: $operationRun,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
extraContext: [
|
extraContext: [
|
||||||
'backup_set_count' => $count,
|
'backup_set_count' => $count,
|
||||||
],
|
],
|
||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('backup_set.delete')
|
OperationUxPresenter::queuedToast('backup_set.delete')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
),
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
UiEnforcement::forBulkAction(
|
||||||
->andHiddenWhen(function (HasTable $livewire): bool {
|
BulkAction::make('bulk_restore')
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
->label('Restore Backup Sets')
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return ! $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->apply(
|
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
||||||
BulkAction::make('bulk_restore')
|
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
||||||
->label('Restore Backup Sets')
|
->action(function (Collection $records) {
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
$tenant = Tenant::current();
|
||||||
->color('success')
|
$user = auth()->user();
|
||||||
->requiresConfirmation()
|
$count = $records->count();
|
||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
$ids = $records->pluck('id')->toArray();
|
||||||
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
|
||||||
->action(function (Collection $records) {
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
if (! $tenant instanceof Tenant) {
|
||||||
$user = auth()->user();
|
return;
|
||||||
$count = $records->count();
|
}
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_set.restore',
|
type: 'backup_set.restore',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
BulkBackupSetRestoreJob::dispatch(
|
BulkBackupSetRestoreJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) ($initiator?->getKey() ?? 0),
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
backupSetIds: $ids,
|
backupSetIds: $ids,
|
||||||
operationRun: $operationRun,
|
operationRun: $operationRun,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
extraContext: [
|
extraContext: [
|
||||||
'backup_set_count' => $count,
|
'backup_set_count' => $count,
|
||||||
],
|
],
|
||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('backup_set.restore')
|
OperationUxPresenter::queuedToast('backup_set.restore')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
),
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
UiEnforcement::forBulkAction(
|
||||||
->andHiddenWhen(function (HasTable $livewire): bool {
|
BulkAction::make('bulk_force_delete')
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
->label('Force Delete Backup Sets')
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
->icon('heroicon-o-trash')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
|
|
||||||
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
$isOnlyTrashed = in_array($value, [0, '0', false], true);
|
||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return ! $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
->apply(
|
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
||||||
BulkAction::make('bulk_force_delete')
|
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
||||||
->label('Force Delete Backup Sets')
|
->form(function (Collection $records) {
|
||||||
->icon('heroicon-o-trash')
|
if ($records->count() >= 10) {
|
||||||
->color('danger')
|
return [
|
||||||
->requiresConfirmation()
|
Forms\Components\TextInput::make('confirmation')
|
||||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} backup sets?")
|
->label('Type DELETE to confirm')
|
||||||
->modalDescription('This is permanent. Only archived backup sets will be permanently deleted; active backup sets will be skipped.')
|
->required()
|
||||||
->form(function (Collection $records) {
|
->in(['DELETE'])
|
||||||
if ($records->count() >= 10) {
|
->validationMessages([
|
||||||
return [
|
'in' => 'Please type DELETE to confirm.',
|
||||||
Forms\Components\TextInput::make('confirmation')
|
]),
|
||||||
->label('Type DELETE to confirm')
|
];
|
||||||
->required()
|
}
|
||||||
->in(['DELETE'])
|
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type DELETE to confirm.',
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)->authorizeOrAbort();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
$count = $records->count();
|
||||||
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
if (! $tenant instanceof Tenant) {
|
||||||
$user = auth()->user();
|
return;
|
||||||
$count = $records->count();
|
}
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_set.force_delete',
|
type: 'backup_set.force_delete',
|
||||||
targetScope: [
|
targetScope: [
|
||||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||||
],
|
],
|
||||||
selectionIdentity: $selectionIdentity,
|
selectionIdentity: $selectionIdentity,
|
||||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||||
BulkBackupSetForceDeleteJob::dispatch(
|
BulkBackupSetForceDeleteJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) ($initiator?->getKey() ?? 0),
|
userId: (int) ($initiator?->getKey() ?? 0),
|
||||||
backupSetIds: $ids,
|
backupSetIds: $ids,
|
||||||
operationRun: $operationRun,
|
operationRun: $operationRun,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
extraContext: [
|
extraContext: [
|
||||||
'backup_set_count' => $count,
|
'backup_set_count' => $count,
|
||||||
],
|
],
|
||||||
emitQueuedNotification: false,
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
),
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -17,6 +16,7 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
@ -41,6 +41,199 @@ public function closeAddPoliciesModal(): void
|
|||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
$refreshTable = Actions\Action::make('refreshTable')
|
||||||
|
->label('Refresh')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->resetTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
$addPolicies = Actions\Action::make('addPolicies')
|
||||||
|
->label('Add Policies')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->tooltip('You do not have permission to add policies.')
|
||||||
|
->modalHeading('Add Policies')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Close')
|
||||||
|
->modalContent(function (): View {
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
return view('filament.modals.backup-set-policy-picker', [
|
||||||
|
'backupSetId' => $backupSet->getKey(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($addPolicies)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to add policies.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$removeItem = Actions\Action::make('remove')
|
||||||
|
->label('Remove')
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (BackupItem $record): void {
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItemIds = [(int) $record->getKey()];
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.remove_policies',
|
||||||
|
inputs: [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'backup_item_ids' => $backupItemIds,
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Removal already queued')
|
||||||
|
->body('A matching remove operation is already queued or running.')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||||
|
RemovePoliciesFromBackupSetJob::dispatch(
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
backupItemIds: $backupItemIds,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($removeItem)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to remove policies.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$bulkRemove = Actions\BulkAction::make('bulk_remove')
|
||||||
|
->label('Remove selected')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->deselectRecordsAfterCompletion()
|
||||||
|
->action(function (Collection $records): void {
|
||||||
|
if ($records->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItemIds = $records
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn (mixed $value): int => (int) $value)
|
||||||
|
->filter(fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($backupItemIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.remove_policies',
|
||||||
|
inputs: [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'backup_item_ids' => $backupItemIds,
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Removal already queued')
|
||||||
|
->body('A matching remove operation is already queued or running.')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||||
|
RemovePoliciesFromBackupSetJob::dispatch(
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
backupItemIds: $backupItemIds,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction($bulkRemove)
|
||||||
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to remove policies.')
|
||||||
|
->apply();
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||||
->columns([
|
->columns([
|
||||||
@ -125,27 +318,8 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Actions\Action::make('refreshTable')
|
$refreshTable,
|
||||||
->label('Refresh')
|
$addPolicies,
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->action(function (): void {
|
|
||||||
$this->resetTable();
|
|
||||||
}),
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
|
||||||
Actions\Action::make('addPolicies')
|
|
||||||
->label('Add Policies')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->modalHeading('Add Policies')
|
|
||||||
->modalSubmitAction(false)
|
|
||||||
->modalCancelActionLabel('Close')
|
|
||||||
->modalContent(function (): View {
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
return view('filament.modals.backup-set-policy-picker', [
|
|
||||||
'backupSetId' => $backupSet->getKey(),
|
|
||||||
]);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
@ -162,156 +336,12 @@ public function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
->hidden(fn (BackupItem $record) => ! $record->policy_id)
|
||||||
->openUrlInNewTab(true),
|
->openUrlInNewTab(true),
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
$removeItem,
|
||||||
Actions\Action::make('remove')
|
|
||||||
->label('Remove')
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (BackupItem $record): void {
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
|
||||||
$tenant = $backupSet->tenant;
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
|
||||||
->tenantFromRecord()
|
|
||||||
->authorizeOrAbort($tenant);
|
|
||||||
|
|
||||||
/** @var User $user */
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
$backupItemIds = [(int) $record->getKey()];
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'backup_set.remove_policies',
|
|
||||||
inputs: [
|
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
|
||||||
'backup_item_ids' => $backupItemIds,
|
|
||||||
],
|
|
||||||
initiator: $user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Removal already queued')
|
|
||||||
->body('A matching remove operation is already queued or running.')
|
|
||||||
->info()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
|
||||||
RemovePoliciesFromBackupSetJob::dispatch(
|
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
|
||||||
backupItemIds: $backupItemIds,
|
|
||||||
initiatorUserId: (int) $user->getKey(),
|
|
||||||
operationRun: $opRun,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\BulkActionGroup::make([
|
Actions\BulkActionGroup::make([
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
$bulkRemove,
|
||||||
Actions\BulkAction::make('bulk_remove')
|
|
||||||
->label('Remove selected')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->deselectRecordsAfterCompletion()
|
|
||||||
->action(function (Collection $records): void {
|
|
||||||
if ($records->isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
|
||||||
$tenant = $backupSet->tenant;
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
|
||||||
->tenantFromRecord()
|
|
||||||
->authorizeOrAbort($tenant);
|
|
||||||
|
|
||||||
/** @var User $user */
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
$backupItemIds = $records
|
|
||||||
->pluck('id')
|
|
||||||
->map(fn (mixed $value): int => (int) $value)
|
|
||||||
->filter(fn (int $value): bool => $value > 0)
|
|
||||||
->unique()
|
|
||||||
->sort()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($backupItemIds === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'backup_set.remove_policies',
|
|
||||||
inputs: [
|
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
|
||||||
'backup_item_ids' => $backupItemIds,
|
|
||||||
],
|
|
||||||
initiator: $user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Removal already queued')
|
|
||||||
->body('A matching remove operation is already queued or running.')
|
|
||||||
->info()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
|
||||||
RemovePoliciesFromBackupSetJob::dispatch(
|
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
|
||||||
backupItemIds: $backupItemIds,
|
|
||||||
initiatorUserId: (int) $user->getKey(),
|
|
||||||
operationRun: $opRun,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,8 @@
|
|||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
@ -29,61 +29,90 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-clock')
|
->icon('heroicon-o-clock')
|
||||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
||||||
->visible(fn (): bool => (bool) Tenant::current()),
|
->visible(fn (): bool => (bool) Tenant::current()),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Action::make('sync_groups')
|
||||||
|
->label('Sync Groups')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('warning')
|
||||||
|
->action(function (): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Action::make('sync_groups')
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
->label('Sync Groups')
|
return;
|
||||||
->icon('heroicon-o-arrow-path')
|
}
|
||||||
->color('warning')
|
|
||||||
->action(function (): void {
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
return;
|
/** @var OperationRunService $opService */
|
||||||
}
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'directory_groups.sync',
|
||||||
|
inputs: ['selection_key' => $selectionKey],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Group sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
// --- Phase 3: Canonical Operation Run Start ---
|
return;
|
||||||
/** @var OperationRunService $opService */
|
}
|
||||||
$opService = app(OperationRunService::class);
|
// ----------------------------------------------
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
$existing = EntraGroupSyncRun::query()
|
||||||
type: 'directory_groups.sync',
|
->where('tenant_id', $tenant->getKey())
|
||||||
inputs: ['selection_key' => $selectionKey],
|
->where('selection_key', $selectionKey)
|
||||||
initiator: $user
|
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||||
);
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing instanceof EntraGroupSyncRun) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Group sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = EntraGroupSyncRun::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'selection_key' => $selectionKey,
|
||||||
|
'slot_key' => null,
|
||||||
|
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||||
|
'initiator_user_id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
dispatch(new EntraGroupSyncJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
selectionKey: $selectionKey,
|
||||||
|
slotKey: null,
|
||||||
|
runId: (int) $run->getKey(),
|
||||||
|
operationRun: $opRun
|
||||||
|
));
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Group sync already active')
|
->title('Group sync started')
|
||||||
->body('This operation is already queued or running.')
|
->body('Sync dispatched.')
|
||||||
->warning()
|
->success()
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ----------------------------------------------
|
|
||||||
|
|
||||||
$existing = EntraGroupSyncRun::query()
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('selection_key', $selectionKey)
|
|
||||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing instanceof EntraGroupSyncRun) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Group sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View Run')
|
->label('View Run')
|
||||||
@ -91,38 +120,11 @@ protected function getHeaderActions(): array
|
|||||||
])
|
])
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
return;
|
)
|
||||||
}
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to sync groups.')
|
||||||
$run = EntraGroupSyncRun::query()->create([
|
->apply(),
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'selection_key' => $selectionKey,
|
|
||||||
'slot_key' => null,
|
|
||||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
|
||||||
'initiator_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
dispatch(new EntraGroupSyncJob(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
selectionKey: $selectionKey,
|
|
||||||
slotKey: null,
|
|
||||||
runId: (int) $run->getKey(),
|
|
||||||
operationRun: $opRun
|
|
||||||
));
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Group sync started')
|
|
||||||
->body('Sync dispatched.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
})),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\Notifications\RunStatusChangedNotification;
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -21,14 +22,12 @@ class ListEntraGroupSyncRuns extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
UiEnforcement::forAction(
|
||||||
Action::make('sync_groups')
|
Action::make('sync_groups')
|
||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
@ -38,49 +37,52 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
$existing = EntraGroupSyncRun::query()
|
$existing = EntraGroupSyncRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('selection_key', $selectionKey)
|
->where('selection_key', $selectionKey)
|
||||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing instanceof EntraGroupSyncRun) {
|
if ($existing instanceof EntraGroupSyncRun) {
|
||||||
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
|
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
|
||||||
|
|
||||||
|
$user->notify(new RunStatusChangedNotification([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'run_type' => 'directory_groups',
|
||||||
|
'run_id' => (int) $existing->getKey(),
|
||||||
|
'status' => $normalizedStatus,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = EntraGroupSyncRun::query()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'selection_key' => $selectionKey,
|
||||||
|
'slot_key' => null,
|
||||||
|
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||||
|
'initiator_user_id' => $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
dispatch(new EntraGroupSyncJob(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
selectionKey: $selectionKey,
|
||||||
|
slotKey: null,
|
||||||
|
runId: (int) $run->getKey(),
|
||||||
|
));
|
||||||
|
|
||||||
$user->notify(new RunStatusChangedNotification([
|
$user->notify(new RunStatusChangedNotification([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'run_type' => 'directory_groups',
|
'run_type' => 'directory_groups',
|
||||||
'run_id' => (int) $existing->getKey(),
|
'run_id' => (int) $run->getKey(),
|
||||||
'status' => $normalizedStatus,
|
'status' => 'queued',
|
||||||
]));
|
]));
|
||||||
|
})
|
||||||
return;
|
)
|
||||||
}
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
$run = EntraGroupSyncRun::query()->create([
|
->apply(),
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'selection_key' => $selectionKey,
|
|
||||||
'slot_key' => null,
|
|
||||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
|
||||||
'initiator_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
dispatch(new EntraGroupSyncJob(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
selectionKey: $selectionKey,
|
|
||||||
slotKey: null,
|
|
||||||
runId: (int) $run->getKey(),
|
|
||||||
));
|
|
||||||
|
|
||||||
$user->notify(new RunStatusChangedNotification([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'run_type' => 'directory_groups',
|
|
||||||
'run_id' => (int) $run->getKey(),
|
|
||||||
'status' => 'queued',
|
|
||||||
]));
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,13 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
@ -29,7 +32,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class FindingResource extends Resource
|
class FindingResource extends Resource
|
||||||
@ -46,19 +48,34 @@ public static function canViewAny(): bool
|
|||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
$user = auth()->user();
|
||||||
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can(Capabilities::TENANT_VIEW, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can(Capabilities::TENANT_VIEW, $tenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -343,75 +360,62 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
BulkAction::make('acknowledge_selected')
|
UiEnforcement::forBulkAction(
|
||||||
->label('Acknowledge selected')
|
BulkAction::make('acknowledge_selected')
|
||||||
->icon('heroicon-o-check')
|
->label('Acknowledge selected')
|
||||||
->color('gray')
|
->icon('heroicon-o-check')
|
||||||
->authorize(function (): bool {
|
->color('gray')
|
||||||
$tenant = Tenant::current();
|
->requiresConfirmation()
|
||||||
$user = auth()->user();
|
->action(function (Collection $records): void {
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return false;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
|
||||||
|
|
||||||
return $user->can('update', $probe);
|
|
||||||
})
|
|
||||||
->authorizeIndividualRecords('update')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (Collection $records): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$firstRecord = $records->first();
|
|
||||||
if ($firstRecord instanceof Finding) {
|
|
||||||
Gate::authorize('update', $firstRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
$acknowledgedCount = 0;
|
|
||||||
$skippedCount = 0;
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
|
||||||
if (! $record instanceof Finding) {
|
|
||||||
$skippedCount++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
$acknowledgedCount = 0;
|
||||||
$skippedCount++;
|
$skippedCount = 0;
|
||||||
|
|
||||||
continue;
|
foreach ($records as $record) {
|
||||||
|
if (! $record instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== Finding::STATUS_NEW) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->acknowledge($user);
|
||||||
|
$acknowledgedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->status !== Finding::STATUS_NEW) {
|
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
||||||
$skippedCount++;
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$record->acknowledge($user);
|
Notification::make()
|
||||||
$acknowledgedCount++;
|
->title('Bulk acknowledge completed')
|
||||||
}
|
->body($body)
|
||||||
|
->success()
|
||||||
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
->send();
|
||||||
if ($skippedCount > 0) {
|
})
|
||||||
$body .= " Skipped {$skippedCount}.";
|
->deselectRecordsAfterCompletion(),
|
||||||
}
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
||||||
Notification::make()
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->title('Bulk acknowledge completed')
|
->apply(),
|
||||||
->body($body)
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion(),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,15 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Models\User;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class ListFindings extends ListRecords
|
class ListFindings extends ListRecords
|
||||||
{
|
{
|
||||||
@ -21,101 +21,83 @@ class ListFindings extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('acknowledge_all_matching')
|
UiEnforcement::forAction(
|
||||||
->label('Acknowledge all matching')
|
Actions\Action::make('acknowledge_all_matching')
|
||||||
->icon('heroicon-o-check')
|
->label('Acknowledge all matching')
|
||||||
->color('gray')
|
->icon('heroicon-o-check')
|
||||||
->requiresConfirmation()
|
->color('gray')
|
||||||
->authorize(function (): bool {
|
->requiresConfirmation()
|
||||||
$tenant = Tenant::current();
|
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
||||||
$user = auth()->user();
|
->modalDescription(function (): string {
|
||||||
|
$count = $this->getAllMatchingCount();
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
||||||
return false;
|
})
|
||||||
}
|
->form(function (): array {
|
||||||
|
$count = $this->getAllMatchingCount();
|
||||||
|
|
||||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
if ($count <= 100) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return $user->can('update', $probe);
|
return [
|
||||||
})
|
TextInput::make('confirmation')
|
||||||
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
->label('Type ACKNOWLEDGE to confirm')
|
||||||
->modalDescription(function (): string {
|
->required()
|
||||||
$count = $this->getAllMatchingCount();
|
->in(['ACKNOWLEDGE'])
|
||||||
|
->validationMessages([
|
||||||
|
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$query = $this->buildAllMatchingQuery();
|
||||||
|
$count = (clone $query)->count();
|
||||||
|
|
||||||
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
if ($count === 0) {
|
||||||
})
|
Notification::make()
|
||||||
->form(function (): array {
|
->title('No matching findings')
|
||||||
$count = $this->getAllMatchingCount();
|
->body('There are no new findings matching the current filters.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
if ($count <= 100) {
|
return;
|
||||||
return [];
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
$updated = $query->update([
|
||||||
TextInput::make('confirmation')
|
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||||
->label('Type ACKNOWLEDGE to confirm')
|
'acknowledged_at' => now(),
|
||||||
->required()
|
'acknowledged_by_user_id' => auth()->id(),
|
||||||
->in(['ACKNOWLEDGE'])
|
]);
|
||||||
->validationMessages([
|
|
||||||
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->action(function (array $data): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant || ! $user instanceof User) {
|
$this->deselectAllTableRecords();
|
||||||
return;
|
$this->resetPage();
|
||||||
}
|
|
||||||
|
|
||||||
$query = $this->buildAllMatchingQuery();
|
|
||||||
$count = (clone $query)->count();
|
|
||||||
|
|
||||||
if ($count === 0) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('No matching findings')
|
->title('Bulk acknowledge completed')
|
||||||
->body('There are no new findings matching the current filters.')
|
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
||||||
->warning()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
return;
|
)
|
||||||
}
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
||||||
$firstRecord = (clone $query)->first();
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
if ($firstRecord instanceof Finding) {
|
->apply(),
|
||||||
Gate::authorize('update', $firstRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $query->update([
|
|
||||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->deselectAllTableRecords();
|
|
||||||
$this->resetPage();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Bulk acknowledge completed')
|
|
||||||
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function buildAllMatchingQuery(): Builder
|
protected function buildAllMatchingQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
$query = Finding::query();
|
$query = Finding::query();
|
||||||
|
|
||||||
if (! $tenant) {
|
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
||||||
|
|
||||||
|
if (! is_numeric($tenantId)) {
|
||||||
return $query->whereRaw('1 = 0');
|
return $query->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->where('tenant_id', $tenant->getKey());
|
$query->where('tenant_id', (int) $tenantId);
|
||||||
|
|
||||||
$query->where('status', Finding::STATUS_NEW);
|
$query->where('status', Finding::STATUS_NEW);
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,11 @@
|
|||||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Inventory\DependencyQueryService;
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -18,7 +19,6 @@
|
|||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -44,18 +44,35 @@ class InventoryItemResource extends Resource
|
|||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
return UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $capabilityResolver->isMember($user, $tenant)
|
||||||
|
&& $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed()) {
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $capabilityResolver->isMember($user, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,12 @@
|
|||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\Action as HintAction;
|
use Filament\Actions\Action as HintAction;
|
||||||
use Filament\Forms\Components\Hidden;
|
use Filament\Forms\Components\Hidden;
|
||||||
@ -40,7 +41,7 @@ protected function getHeaderWidgets(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(
|
UiEnforcement::forAction(
|
||||||
Action::make('run_inventory_sync')
|
Action::make('run_inventory_sync')
|
||||||
->label('Run Inventory Sync')
|
->label('Run Inventory Sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
@ -106,9 +107,20 @@ protected function getHeaderActions(): array
|
|||||||
->default(fn (): ?string => Tenant::current()?->getKey())
|
->default(fn (): ?string => Tenant::current()?->getKey())
|
||||||
->dehydrated(),
|
->dehydrated(),
|
||||||
])
|
])
|
||||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
->visible(function (): bool {
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->authorizeOrAbort();
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant);
|
||||||
|
})
|
||||||
|
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -123,113 +135,117 @@ protected function getHeaderActions(): array
|
|||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
throw new \Symfony\Component\HttpKernel\Exception\HttpException(403, 'Not allowed');
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
$selectionPayload = $inventorySyncService->defaultSelectionPayload();
|
||||||
if (array_key_exists('policy_types', $data)) {
|
if (array_key_exists('policy_types', $data)) {
|
||||||
$selectionPayload['policy_types'] = $data['policy_types'];
|
$selectionPayload['policy_types'] = $data['policy_types'];
|
||||||
}
|
}
|
||||||
if (array_key_exists('include_foundations', $data)) {
|
if (array_key_exists('include_foundations', $data)) {
|
||||||
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
|
$selectionPayload['include_foundations'] = (bool) $data['include_foundations'];
|
||||||
}
|
}
|
||||||
if (array_key_exists('include_dependencies', $data)) {
|
if (array_key_exists('include_dependencies', $data)) {
|
||||||
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
|
$selectionPayload['include_dependencies'] = (bool) $data['include_dependencies'];
|
||||||
}
|
}
|
||||||
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
|
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRun(
|
$opRun = $opService->ensureRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'inventory.sync',
|
type: 'inventory.sync',
|
||||||
inputs: $computed['selection'],
|
inputs: $computed['selection'],
|
||||||
initiator: $user
|
initiator: $user
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync already active')
|
->title('Inventory sync already active')
|
||||||
->body('This operation is already queued or running.')
|
->body('This operation is already queued or running.')
|
||||||
->warning()
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
|
||||||
|
$existing = InventorySyncRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('selection_hash', $computed['selection_hash'])
|
||||||
|
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
|
||||||
|
if ($existing instanceof InventorySyncRun) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Inventory sync already active')
|
||||||
|
->body('A matching inventory sync run is already pending or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
|
||||||
|
|
||||||
|
$policyTypes = $computed['selection']['policy_types'] ?? [];
|
||||||
|
if (! is_array($policyTypes)) {
|
||||||
|
$policyTypes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'inventory.sync.dispatched',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'inventory_sync_run_id' => $run->id,
|
||||||
|
'selection_hash' => $run->selection_hash,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $user->id,
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
resourceType: 'inventory_sync_run',
|
||||||
|
resourceId: (string) $run->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
|
||||||
|
RunInventorySyncJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
inventorySyncRunId: (int) $run->id,
|
||||||
|
operationRun: $opRun
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View Run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
})
|
||||||
return;
|
)
|
||||||
}
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)
|
||||||
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
$existing = InventorySyncRun::query()
|
->apply(),
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('selection_hash', $computed['selection_hash'])
|
|
||||||
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
|
|
||||||
if ($existing instanceof InventorySyncRun) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Inventory sync already active')
|
|
||||||
->body('A matching inventory sync run is already pending or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View Run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
|
|
||||||
|
|
||||||
$policyTypes = $computed['selection']['policy_types'] ?? [];
|
|
||||||
if (! is_array($policyTypes)) {
|
|
||||||
$policyTypes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'inventory.sync.dispatched',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'inventory_sync_run_id' => $run->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $user->id,
|
|
||||||
actorEmail: $user->email,
|
|
||||||
actorName: $user->name,
|
|
||||||
resourceType: 'inventory_sync_run',
|
|
||||||
resourceId: (string) $run->id,
|
|
||||||
);
|
|
||||||
|
|
||||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
|
|
||||||
RunInventorySyncJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
inventorySyncRunId: (int) $run->id,
|
|
||||||
operationRun: $opRun
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,14 +6,14 @@
|
|||||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -41,18 +41,32 @@ class InventorySyncRunResource extends Resource
|
|||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
return UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! UiEnforcement::for(Capabilities::TENANT_VIEW)->isAllowed()) {
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -11,10 +11,10 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class ListPolicies extends ListRecords
|
class ListPolicies extends ListRecords
|
||||||
{
|
{
|
||||||
@ -23,109 +23,70 @@ class ListPolicies extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('sync')
|
UiEnforcement::forAction(
|
||||||
->label('Sync from Intune')
|
Actions\Action::make('sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->label('Sync from Intune')
|
||||||
->color('primary')
|
->icon('heroicon-o-arrow-path')
|
||||||
->requiresConfirmation()
|
->color('primary')
|
||||||
->visible(function (): bool {
|
->action(function (self $livewire): void {
|
||||||
$user = auth()->user();
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
return false;
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$requestedTypes = array_map(
|
||||||
|
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||||
|
config('tenantpilot.supported_policy_types', [])
|
||||||
|
);
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
sort($requestedTypes);
|
||||||
&& $user->canAccessTenant($tenant);
|
|
||||||
})
|
|
||||||
->disabled(function (): bool {
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return ! ($user instanceof User
|
/** @var OperationRunService $opService */
|
||||||
&& $tenant instanceof Tenant
|
$opService = app(OperationRunService::class);
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
|
$opRun = $opService->ensureRun(
|
||||||
})
|
tenant: $tenant,
|
||||||
->tooltip(function (): ?string {
|
type: 'policy.sync',
|
||||||
$user = auth()->user();
|
inputs: [
|
||||||
$tenant = Tenant::current();
|
'scope' => 'all',
|
||||||
|
'types' => $requestedTypes,
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
if (! ($user instanceof User && $tenant instanceof Tenant)) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
return null;
|
Notification::make()
|
||||||
}
|
->title('Policy sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
|
return;
|
||||||
? null
|
}
|
||||||
: 'You do not have permission to sync policies.';
|
|
||||||
})
|
|
||||||
->action(function (self $livewire): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
|
||||||
abort(403);
|
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
|
||||||
}
|
});
|
||||||
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
if (! $tenant instanceof Tenant) {
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestedTypes = array_map(
|
|
||||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
|
||||||
config('tenantpilot.supported_policy_types', [])
|
|
||||||
);
|
|
||||||
|
|
||||||
sort($requestedTypes);
|
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.sync',
|
|
||||||
inputs: [
|
|
||||||
'scope' => 'all',
|
|
||||||
'types' => $requestedTypes,
|
|
||||||
],
|
|
||||||
initiator: $user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
return;
|
)
|
||||||
}
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
->tooltip('You do not have permission to sync policies.')
|
||||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
|
->destructive()
|
||||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
|
->apply(),
|
||||||
});
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,17 +5,20 @@
|
|||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class VersionsRelationManager extends RelationManager
|
class VersionsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@ -23,6 +26,116 @@ class VersionsRelationManager extends RelationManager
|
|||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
|
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||||
|
->label('Restore to Intune')
|
||||||
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||||
|
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||||
|
->form([
|
||||||
|
Forms\Components\Toggle::make('is_dry_run')
|
||||||
|
->label('Preview only (dry-run)')
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Missing tenant or user context.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->tenant_id !== $tenant->id) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy version belongs to a different tenant')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$run = $restoreService->executeFromPolicyVersion(
|
||||||
|
tenant: $tenant,
|
||||||
|
version: $record,
|
||||||
|
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||||
|
actorEmail: $user->email,
|
||||||
|
actorName: $user->name,
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run failed to start')
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Restore run started')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
||||||
|
});
|
||||||
|
|
||||||
|
UiEnforcement::forAction($restoreToIntune)
|
||||||
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
|
->apply();
|
||||||
|
|
||||||
|
$restoreToIntune
|
||||||
|
->disabled(function (PolicyVersion $record): bool {
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
|
})
|
||||||
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
return UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||||
@ -38,61 +151,7 @@ public function table(Table $table): Table
|
|||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('restore_to_intune')
|
$restoreToIntune,
|
||||||
->label('Restore to Intune')
|
|
||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
|
||||||
->color('danger')
|
|
||||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
|
||||||
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
|
||||||
->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant
|
|
||||||
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant))
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
|
||||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
|
||||||
->form([
|
|
||||||
Forms\Components\Toggle::make('is_dry_run')
|
|
||||||
->label('Preview only (dry-run)')
|
|
||||||
->default(true),
|
|
||||||
])
|
|
||||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
|
||||||
|
|
||||||
if ($record->tenant_id !== $tenant->id) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy version belongs to a different tenant')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$run = $restoreService->executeFromPolicyVersion(
|
|
||||||
tenant: $tenant,
|
|
||||||
version: $record,
|
|
||||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
|
||||||
actorEmail: auth()->user()?->email,
|
|
||||||
actorName: auth()->user()?->name,
|
|
||||||
);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Restore run failed to start')
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Restore run started')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
|
|
||||||
}),
|
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -11,14 +11,15 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -48,6 +49,22 @@ class ProviderConnectionResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'display_name';
|
protected static ?string $recordTitleAttribute = 'display_name';
|
||||||
|
|
||||||
|
protected static function hasTenantCapability(string $capability): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, $capability);
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -55,17 +72,17 @@ public static function form(Schema $schema): Schema
|
|||||||
TextInput::make('display_name')
|
TextInput::make('display_name')
|
||||||
->label('Display name')
|
->label('Display name')
|
||||||
->required()
|
->required()
|
||||||
->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed())
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Entra tenant ID')
|
->label('Entra tenant ID')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed())
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->rules(['uuid']),
|
->rules(['uuid']),
|
||||||
Toggle::make('is_default')
|
Toggle::make('is_default')
|
||||||
->label('Default connection')
|
->label('Default connection')
|
||||||
->disabled(fn (): bool => ! UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->isAllowed())
|
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
|
||||||
->helperText('Exactly one default connection is required per tenant/provider.'),
|
->helperText('Exactly one default connection is required per tenant/provider.'),
|
||||||
TextInput::make('status')
|
TextInput::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
@ -146,451 +163,473 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(
|
UiEnforcement::forAction(
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make()
|
||||||
),
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('check_connection')
|
UiEnforcement::forAction(
|
||||||
->label('Check connection')
|
Actions\Action::make('check_connection')
|
||||||
->icon('heroicon-o-check-badge')
|
->label('Check connection')
|
||||||
->color('success')
|
->icon('heroicon-o-check-badge')
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
->color('success')
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort();
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
$user = auth()->user();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
$initiator = $user;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$initiator = $user;
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $record,
|
||||||
|
operationType: 'provider.connection.check',
|
||||||
|
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||||
|
ProviderConnectionHealthCheckJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
providerConnectionId: (int) $record->getKey(),
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
$result = $gate->start(
|
if ($result->status === 'scope_busy') {
|
||||||
tenant: $tenant,
|
Notification::make()
|
||||||
connection: $record,
|
->title('Scope busy')
|
||||||
operationType: 'provider.connection.check',
|
->body('Another provider operation is already running for this connection.')
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
->warning()
|
||||||
ProviderConnectionHealthCheckJob::dispatch(
|
->actions([
|
||||||
tenantId: (int) $tenant->getKey(),
|
Actions\Action::make('view_run')
|
||||||
userId: (int) $initiator->getKey(),
|
->label('View run')
|
||||||
providerConnectionId: (int) $record->getKey(),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
operationRun: $operationRun,
|
])
|
||||||
);
|
->send();
|
||||||
},
|
|
||||||
initiator: $initiator,
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Run already queued')
|
||||||
|
->body('A connection check is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Scope busy')
|
->title('Connection check queued')
|
||||||
->body('Another provider operation is already running for this connection.')
|
->body('Health check was queued and will run in the background.')
|
||||||
->warning()
|
->success()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
return;
|
UiEnforcement::forAction(
|
||||||
}
|
Actions\Action::make('inventory_sync')
|
||||||
|
->label('Inventory sync')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('info')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$initiator = $user;
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $record,
|
||||||
|
operationType: 'inventory.sync',
|
||||||
|
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||||
|
ProviderInventorySyncJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
providerConnectionId: (int) $record->getKey(),
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Scope is busy')
|
||||||
|
->body('Another provider operation is already running for this connection.')
|
||||||
|
->danger()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->status === 'deduped') {
|
||||||
|
Notification::make()
|
||||||
|
->title('Run already queued')
|
||||||
|
->body('An inventory sync is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Run already queued')
|
->title('Inventory sync queued')
|
||||||
->body('A connection check is already queued or running.')
|
->body('Inventory sync was queued and will run in the background.')
|
||||||
->warning()
|
->success()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
return;
|
UiEnforcement::forAction(
|
||||||
}
|
Actions\Action::make('compliance_snapshot')
|
||||||
|
->label('Compliance snapshot')
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('info')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
Notification::make()
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
->title('Connection check queued')
|
return;
|
||||||
->body('Health check was queued and will run in the background.')
|
}
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})),
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('inventory_sync')
|
$initiator = $user;
|
||||||
->label('Inventory sync')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('info')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$result = $gate->start(
|
||||||
$user = auth()->user();
|
tenant: $tenant,
|
||||||
|
connection: $record,
|
||||||
|
operationType: 'compliance.snapshot',
|
||||||
|
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||||
|
ProviderComplianceSnapshotJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $initiator->getKey(),
|
||||||
|
providerConnectionId: (int) $record->getKey(),
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $initiator,
|
||||||
|
);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
if ($result->status === 'scope_busy') {
|
||||||
return;
|
Notification::make()
|
||||||
}
|
->title('Scope is busy')
|
||||||
|
->body('Another provider operation is already running for this connection.')
|
||||||
|
->danger()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
$initiator = $user;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $gate->start(
|
if ($result->status === 'deduped') {
|
||||||
tenant: $tenant,
|
Notification::make()
|
||||||
connection: $record,
|
->title('Run already queued')
|
||||||
operationType: 'inventory.sync',
|
->body('A compliance snapshot is already queued or running.')
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
->warning()
|
||||||
ProviderInventorySyncJob::dispatch(
|
->actions([
|
||||||
tenantId: (int) $tenant->getKey(),
|
Actions\Action::make('view_run')
|
||||||
userId: (int) $initiator->getKey(),
|
->label('View run')
|
||||||
providerConnectionId: (int) $record->getKey(),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
operationRun: $operationRun,
|
])
|
||||||
);
|
->send();
|
||||||
},
|
|
||||||
initiator: $initiator,
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Scope is busy')
|
->title('Compliance snapshot queued')
|
||||||
->body('Another provider operation is already running for this connection.')
|
->body('Compliance snapshot was queued and will run in the background.')
|
||||||
->danger()
|
->success()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
return;
|
UiEnforcement::forAction(
|
||||||
}
|
Actions\Action::make('set_default')
|
||||||
|
->label('Set as default')
|
||||||
|
->icon('heroicon-o-star')
|
||||||
|
->color('primary')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
||||||
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
if (! $tenant instanceof Tenant) {
|
||||||
Notification::make()
|
return;
|
||||||
->title('Run already queued')
|
}
|
||||||
->body('An inventory sync is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
$record->makeDefault();
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
$user = auth()->user();
|
||||||
->title('Inventory sync queued')
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||||
->body('Inventory sync was queued and will run in the background.')
|
$actorEmail = $user instanceof User ? $user->email : null;
|
||||||
->success()
|
$actorName = $user instanceof User ? $user->name : null;
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})),
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_RUN)->apply(Actions\Action::make('compliance_snapshot')
|
$auditLogger->log(
|
||||||
->label('Compliance snapshot')
|
tenant: $tenant,
|
||||||
->icon('heroicon-o-shield-check')
|
action: 'provider_connection.default_set',
|
||||||
->color('info')
|
context: [
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
'metadata' => [
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
'provider' => $record->provider,
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_RUN)->authorizeOrAbort();
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
],
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$initiator = $user;
|
|
||||||
|
|
||||||
$result = $gate->start(
|
|
||||||
tenant: $tenant,
|
|
||||||
connection: $record,
|
|
||||||
operationType: 'compliance.snapshot',
|
|
||||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
|
||||||
ProviderComplianceSnapshotJob::dispatch(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $initiator->getKey(),
|
|
||||||
providerConnectionId: (int) $record->getKey(),
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Scope is busy')
|
|
||||||
->body('Another provider operation is already running for this connection.')
|
|
||||||
->danger()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
|
||||||
Notification::make()
|
|
||||||
->title('Run already queued')
|
|
||||||
->body('A compliance snapshot is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Compliance snapshot queued')
|
|
||||||
->body('Compliance snapshot was queued and will run in the background.')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})),
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('set_default')
|
|
||||||
->label('Set as default')
|
|
||||||
->icon('heroicon-o-star')
|
|
||||||
->color('primary')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default)
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->makeDefault();
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.default_set',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
],
|
||||||
],
|
actorId: $actorId,
|
||||||
actorId: $actorId,
|
actorEmail: $actorEmail,
|
||||||
actorEmail: $actorEmail,
|
actorName: $actorName,
|
||||||
actorName: $actorName,
|
resourceType: 'provider_connection',
|
||||||
resourceType: 'provider_connection',
|
resourceId: (string) $record->getKey(),
|
||||||
resourceId: (string) $record->getKey(),
|
status: 'success',
|
||||||
status: 'success',
|
);
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Default connection updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})),
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('update_credentials')
|
|
||||||
->label('Update credentials')
|
|
||||||
->icon('heroicon-o-key')
|
|
||||||
->color('primary')
|
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
|
||||||
->form([
|
|
||||||
TextInput::make('client_id')
|
|
||||||
->label('Client ID')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('client_secret')
|
|
||||||
->label('Client secret')
|
|
||||||
->password()
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
])
|
|
||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
|
||||||
connection: $record,
|
|
||||||
clientId: (string) $data['client_id'],
|
|
||||||
clientSecret: (string) $data['client_secret'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.credentials_updated',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Credentials updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})),
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('enable_connection')
|
|
||||||
->label('Enable connection')
|
|
||||||
->icon('heroicon-o-play')
|
|
||||||
->color('success')
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
|
||||||
$previousStatus = (string) $record->status;
|
|
||||||
|
|
||||||
$record->update([
|
|
||||||
'status' => $status,
|
|
||||||
'health_status' => 'unknown',
|
|
||||||
'last_health_check_at' => null,
|
|
||||||
'last_error_reason_code' => null,
|
|
||||||
'last_error_message' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.enabled',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_status' => $previousStatus,
|
|
||||||
'to_status' => $status,
|
|
||||||
'credentials_present' => $hadCredentials,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $hadCredentials) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection enabled (credentials missing)')
|
->title('Default connection updated')
|
||||||
->body('Add credentials before running checks or operations.')
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('update_credentials')
|
||||||
|
->label('Update credentials')
|
||||||
|
->icon('heroicon-o-key')
|
||||||
|
->color('primary')
|
||||||
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
|
->form([
|
||||||
|
TextInput::make('client_id')
|
||||||
|
->label('Client ID')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('client_secret')
|
||||||
|
->label('Client secret')
|
||||||
|
->password()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$credentials->upsertClientSecretCredential(
|
||||||
|
connection: $record,
|
||||||
|
clientId: (string) $data['client_id'],
|
||||||
|
clientSecret: (string) $data['client_secret'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||||
|
$actorEmail = $user instanceof User ? $user->email : null;
|
||||||
|
$actorName = $user instanceof User ? $user->name : null;
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.credentials_updated',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'provider' => $record->provider,
|
||||||
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Credentials updated')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('enable_connection')
|
||||||
|
->label('Enable connection')
|
||||||
|
->icon('heroicon-o-play')
|
||||||
|
->color('success')
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hadCredentials = $record->credential()->exists();
|
||||||
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
|
$record->update([
|
||||||
|
'status' => $status,
|
||||||
|
'health_status' => 'unknown',
|
||||||
|
'last_health_check_at' => null,
|
||||||
|
'last_error_reason_code' => null,
|
||||||
|
'last_error_message' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||||
|
$actorEmail = $user instanceof User ? $user->email : null;
|
||||||
|
$actorName = $user instanceof User ? $user->name : null;
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.enabled',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'provider' => $record->provider,
|
||||||
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
'from_status' => $previousStatus,
|
||||||
|
'to_status' => $status,
|
||||||
|
'credentials_present' => $hadCredentials,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $hadCredentials) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Connection enabled (credentials missing)')
|
||||||
|
->body('Add credentials before running checks or operations.')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Provider connection enabled')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('disable_connection')
|
||||||
|
->label('Disable connection')
|
||||||
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
||||||
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
|
$record->update([
|
||||||
|
'status' => 'disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
||||||
|
$actorEmail = $user instanceof User ? $user->email : null;
|
||||||
|
$actorName = $user instanceof User ? $user->name : null;
|
||||||
|
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'provider_connection.disabled',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'provider' => $record->provider,
|
||||||
|
'entra_tenant_id' => $record->entra_tenant_id,
|
||||||
|
'from_status' => $previousStatus,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $actorId,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
resourceType: 'provider_connection',
|
||||||
|
resourceId: (string) $record->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Provider connection disabled')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
})
|
||||||
return;
|
)
|
||||||
}
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
Notification::make()
|
->apply(),
|
||||||
->title('Provider connection enabled')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
})),
|
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(Actions\Action::make('disable_connection')
|
|
||||||
->label('Disable connection')
|
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled')
|
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->authorizeOrAbort();
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previousStatus = (string) $record->status;
|
|
||||||
|
|
||||||
$record->update([
|
|
||||||
'status' => 'disabled',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$actorId = $user instanceof User ? (int) $user->getKey() : null;
|
|
||||||
$actorEmail = $user instanceof User ? $user->email : null;
|
|
||||||
$actorName = $user instanceof User ? $user->name : null;
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'provider_connection.disabled',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'provider' => $record->provider,
|
|
||||||
'entra_tenant_id' => $record->entra_tenant_id,
|
|
||||||
'from_status' => $previousStatus,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: $actorId,
|
|
||||||
actorEmail: $actorEmail,
|
|
||||||
actorName: $actorName,
|
|
||||||
resourceType: 'provider_connection',
|
|
||||||
resourceId: (string) $record->getKey(),
|
|
||||||
status: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Provider connection disabled')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
})),
|
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
use App\Filament\Resources\ProviderConnectionResource;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -15,9 +15,13 @@ class ListProviderConnections extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
UiEnforcement::for(Capabilities::PROVIDER_MANAGE)->apply(
|
UiEnforcement::forAction(
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
),
|
->authorize(fn (): bool => true)
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE)
|
||||||
|
->tooltip('You do not have permission to create provider connections.')
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -5,8 +5,9 @@
|
|||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
@ -21,7 +22,23 @@ class CreateRestoreRun extends CreateRecord
|
|||||||
|
|
||||||
protected function authorizeAccess(): void
|
protected function authorizeAccess(): void
|
||||||
{
|
{
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->authorizeOrAbort();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $capabilityResolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSteps(): array
|
public function getSteps(): array
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\RoleCapabilityMap;
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
@ -20,7 +21,6 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -73,16 +73,32 @@ public static function canCreate(): bool
|
|||||||
|
|
||||||
public static function canEdit(Model $record): bool
|
public static function canEdit(Model $record): bool
|
||||||
{
|
{
|
||||||
return UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
$user = auth()->user();
|
||||||
->tenantFromRecord()
|
|
||||||
->isAllowed($record);
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $record instanceof Tenant
|
||||||
|
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDelete(Model $record): bool
|
public static function canDelete(Model $record): bool
|
||||||
{
|
{
|
||||||
return UiEnforcement::for(Capabilities::TENANT_DELETE)
|
$user = auth()->user();
|
||||||
->tenantFromRecord()
|
|
||||||
->isAllowed($record);
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $record instanceof Tenant
|
||||||
|
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDeleteAny(): bool
|
public static function canDeleteAny(): bool
|
||||||
@ -98,30 +114,16 @@ public static function canDeleteAny(): bool
|
|||||||
|
|
||||||
private static function userCanManageAnyTenant(User $user): bool
|
private static function userCanManageAnyTenant(User $user): bool
|
||||||
{
|
{
|
||||||
$roles = RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_MANAGE);
|
return $user->tenantMemberships()
|
||||||
|
->pluck('role')
|
||||||
if ($roles === []) {
|
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->tenants()
|
|
||||||
->withTrashed()
|
|
||||||
->wherePivotIn('role', $roles)
|
|
||||||
->exists();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function userCanDeleteAnyTenant(User $user): bool
|
private static function userCanDeleteAnyTenant(User $user): bool
|
||||||
{
|
{
|
||||||
$roles = RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_DELETE);
|
return $user->tenantMemberships()
|
||||||
|
->pluck('role')
|
||||||
if ($roles === []) {
|
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->tenants()
|
|
||||||
->withTrashed()
|
|
||||||
->wherePivotIn('role', $roles)
|
|
||||||
->exists();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -260,20 +262,68 @@ public static function table(Table $table): Table
|
|||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)->tenantFromRecord()->apply(Actions\Action::make('syncTenant')
|
Actions\Action::make('syncTenant')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(function (Tenant $record): bool {
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
if (! $record->isActive()) {
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
return false;
|
||||||
->tenantFromRecord()
|
}
|
||||||
->authorizeOrAbort($record);
|
|
||||||
|
|
||||||
/** @var User $user */
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($record);
|
||||||
|
})
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $record, Capabilities::TENANT_SYNC);
|
||||||
|
})
|
||||||
|
->tooltip(function (Tenant $record): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $record, Capabilities::TENANT_SYNC)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to sync this tenant.';
|
||||||
|
})
|
||||||
|
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($record)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
@ -293,7 +343,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $record,
|
tenant: $record,
|
||||||
type: 'policy.sync',
|
type: 'policy.sync',
|
||||||
inputs: $inputs,
|
inputs: $inputs,
|
||||||
initiator: $user
|
initiator: auth()->user()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||||
@ -306,7 +356,7 @@ public static function table(Table $table): Table
|
|||||||
tenant: $record,
|
tenant: $record,
|
||||||
type: 'policy.sync',
|
type: 'policy.sync',
|
||||||
inputs: $inputs,
|
inputs: $inputs,
|
||||||
initiator: $user
|
initiator: auth()->user()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,29 +396,50 @@ public static function table(Table $table): Table
|
|||||||
->url(OperationRunLinks::view($opRun, $record)),
|
->url(OperationRunLinks::view($opRun, $record)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})),
|
}),
|
||||||
Actions\Action::make('openTenant')
|
Actions\Action::make('openTenant')
|
||||||
->label('Open')
|
->label('Open')
|
||||||
->icon('heroicon-o-arrow-right')
|
->icon('heroicon-o-arrow-right')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
||||||
->visible(fn (Tenant $record) => $record->isActive()),
|
->visible(fn (Tenant $record) => $record->isActive()),
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(
|
Actions\Action::make('edit')
|
||||||
Actions\Action::make('edit')
|
->label('Edit')
|
||||||
->label('Edit')
|
->icon('heroicon-o-pencil-square')
|
||||||
->icon('heroicon-o-pencil-square')
|
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record)),
|
->disabled(fn (Tenant $record): bool => ! static::canEdit($record))
|
||||||
),
|
->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'),
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
->successNotificationTitle('Tenant reactivated')
|
->successNotificationTitle('Tenant reactivated')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||||
|
})
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
$user = auth()->user();
|
||||||
->tenantFromRecord()
|
|
||||||
->authorizeOrAbort($record);
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
@ -380,25 +451,63 @@ public static function table(Table $table): Table
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||||
);
|
);
|
||||||
})),
|
}),
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Admin consent')
|
->label('Admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||||
->openUrlInNewTab()),
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||||
|
})
|
||||||
|
->tooltip(function (Tenant $record): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $record, Capabilities::TENANT_MANAGE)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage tenant consent.';
|
||||||
|
})
|
||||||
|
->openUrlInNewTab(),
|
||||||
Actions\Action::make('open_in_entra')
|
Actions\Action::make('open_in_entra')
|
||||||
->label('Open in Entra')
|
->label('Open in Entra')
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('verify')
|
Actions\Action::make('verify')
|
||||||
->label('Verify configuration')
|
->label('Verify configuration')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||||
|
})
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
TenantConfigService $configService,
|
TenantConfigService $configService,
|
||||||
@ -406,23 +515,53 @@ public static function table(Table $table): Table
|
|||||||
RbacHealthService $rbacHealthService,
|
RbacHealthService $rbacHealthService,
|
||||||
AuditLogger $auditLogger
|
AuditLogger $auditLogger
|
||||||
) {
|
) {
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
$user = auth()->user();
|
||||||
->tenantFromRecord()
|
|
||||||
->authorizeOrAbort($record);
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||||
})),
|
}),
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('archive')
|
Actions\Action::make('archive')
|
||||||
->label('Deactivate')
|
->label('Deactivate')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||||
|
})
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
$user = auth()->user();
|
||||||
->tenantFromRecord()
|
|
||||||
->authorizeOrAbort($record);
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
@ -440,21 +579,46 @@ public static function table(Table $table): Table
|
|||||||
->body('The tenant has been archived and hidden from lists.')
|
->body('The tenant has been archived and hidden from lists.')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
})),
|
}),
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)->tenantFromRecord()->apply(Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
||||||
|
->disabled(function (?Tenant $record): bool {
|
||||||
|
if (! $record instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||||
|
})
|
||||||
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
||||||
if ($record === null) {
|
if ($record === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
$user = auth()->user();
|
||||||
->tenantFromRecord()
|
|
||||||
->authorizeOrAbort($record);
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::withTrashed()->find($record->id);
|
$tenant = Tenant::withTrashed()->find($record->id);
|
||||||
|
|
||||||
@ -482,100 +646,110 @@ public static function table(Table $table): Table
|
|||||||
->title('Tenant permanently deleted')
|
->title('Tenant permanently deleted')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
})),
|
}),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
Actions\BulkAction::make('syncSelected')
|
||||||
->tenantFromRecord()
|
->label('Sync selected')
|
||||||
->preflightByCapability()
|
->icon('heroicon-o-arrow-path')
|
||||||
->apply(Actions\BulkAction::make('syncSelected')
|
->color('warning')
|
||||||
->label('Sync selected')
|
->requiresConfirmation()
|
||||||
->icon('heroicon-o-arrow-path')
|
->visible(function (): bool {
|
||||||
->color('warning')
|
$user = auth()->user();
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
|
||||||
UiEnforcement::for(Capabilities::TENANT_SYNC)
|
|
||||||
->tenantFromRecord()
|
|
||||||
->authorizeBulkSelectionOrAbort($records);
|
|
||||||
|
|
||||||
/** @var User $user */
|
if (! $user instanceof User) {
|
||||||
$user = auth()->user();
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$eligible = $records
|
return $user->tenants()
|
||||||
->filter(fn ($record) => $record instanceof Tenant && $record->isActive());
|
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
||||||
|
->exists();
|
||||||
|
})
|
||||||
|
->authorize(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if ($eligible->isEmpty()) {
|
if (! $user instanceof User) {
|
||||||
Notification::make()
|
return false;
|
||||||
->title('Bulk sync skipped')
|
}
|
||||||
->body('No eligible tenants selected.')
|
|
||||||
->icon('heroicon-o-information-circle')
|
|
||||||
->info()
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return $user->tenants()
|
||||||
}
|
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
||||||
|
->exists();
|
||||||
|
})
|
||||||
|
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
if ($eligible->count() !== $records->count()) {
|
if (! $user instanceof User) {
|
||||||
$skipped = $records->count() - $eligible->count();
|
return;
|
||||||
$total = $records->count();
|
}
|
||||||
|
|
||||||
Notification::make()
|
/** @var CapabilityResolver $resolver */
|
||||||
->title('Some tenants were skipped')
|
$resolver = app(CapabilityResolver::class);
|
||||||
->body("Skipped {$skipped} of {$total} selected tenants (inactive).")
|
|
||||||
->warning()
|
|
||||||
->sendToDatabase($user)
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantContext = Tenant::current() ?? $eligible->first();
|
$eligible = $records
|
||||||
|
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
|
||||||
|
->filter(fn (Tenant $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||||
|
|
||||||
if (! $tenantContext) {
|
if ($eligible->isEmpty()) {
|
||||||
return;
|
Notification::make()
|
||||||
}
|
->title('Bulk sync skipped')
|
||||||
|
->body('No eligible tenants selected.')
|
||||||
$ids = $eligible->pluck('id')->toArray();
|
->icon('heroicon-o-information-circle')
|
||||||
$count = $eligible->count();
|
->info()
|
||||||
|
->sendToDatabase($user)
|
||||||
/** @var BulkSelectionIdentity $selection */
|
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
|
||||||
$selectionIdentity = $selection->fromIds($ids);
|
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
|
||||||
$runs = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $runs->enqueueBulkOperation(
|
|
||||||
tenant: $tenantContext,
|
|
||||||
type: 'tenant.sync',
|
|
||||||
targetScope: [
|
|
||||||
'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id),
|
|
||||||
],
|
|
||||||
selectionIdentity: $selectionIdentity,
|
|
||||||
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
|
|
||||||
BulkTenantSyncJob::dispatch(
|
|
||||||
tenantId: (int) $tenantContext->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
tenantIds: $ids,
|
|
||||||
operationRun: $operationRun,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
initiator: $user,
|
|
||||||
extraContext: [
|
|
||||||
'tenant_count' => $count,
|
|
||||||
],
|
|
||||||
emitQueuedNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('tenant.sync')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenantContext)),
|
|
||||||
])
|
|
||||||
->send();
|
->send();
|
||||||
})
|
|
||||||
->deselectRecordsAfterCompletion()),
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantContext = Tenant::current() ?? $eligible->first();
|
||||||
|
|
||||||
|
if (! $tenantContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $eligible->pluck('id')->toArray();
|
||||||
|
$count = $eligible->count();
|
||||||
|
|
||||||
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
|
tenant: $tenantContext,
|
||||||
|
type: 'tenant.sync',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
|
||||||
|
BulkTenantSyncJob::dispatch(
|
||||||
|
tenantId: (int) $tenantContext->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
tenantIds: $ids,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'tenant_count' => $count,
|
||||||
|
],
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('tenant.sync')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenantContext)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
])
|
])
|
||||||
->headerActions([]);
|
->headerActions([]);
|
||||||
}
|
}
|
||||||
@ -668,7 +842,7 @@ public static function getRelations(): array
|
|||||||
public static function rbacAction(): Actions\Action
|
public static function rbacAction(): Actions\Action
|
||||||
{
|
{
|
||||||
// ... [RBAC Action Omitted - No Change] ...
|
// ... [RBAC Action Omitted - No Change] ...
|
||||||
return UiEnforcement::for(Capabilities::TENANT_MANAGE)->tenantFromRecord()->apply(Actions\Action::make('setup_rbac')
|
return Actions\Action::make('setup_rbac')
|
||||||
->label('Setup Intune RBAC')
|
->label('Setup Intune RBAC')
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
@ -751,6 +925,18 @@ public static function rbacAction(): Actions\Action
|
|||||||
->loadingMessage('Searching groups...'),
|
->loadingMessage('Searching groups...'),
|
||||||
])
|
])
|
||||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||||
|
})
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (
|
->action(function (
|
||||||
array $data,
|
array $data,
|
||||||
@ -758,9 +944,18 @@ public static function rbacAction(): Actions\Action
|
|||||||
RbacOnboardingService $service,
|
RbacOnboardingService $service,
|
||||||
AuditLogger $auditLogger
|
AuditLogger $auditLogger
|
||||||
) {
|
) {
|
||||||
UiEnforcement::for(Capabilities::TENANT_MANAGE)
|
$user = auth()->user();
|
||||||
->tenantFromRecord()
|
|
||||||
->authorizeOrAbort($record);
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
||||||
$token = Cache::get($cacheKey);
|
$token = Cache::get($cacheKey);
|
||||||
@ -839,7 +1034,7 @@ public static function rbacAction(): Actions\Action
|
|||||||
->body($result['message'] ?? 'Unknown error')
|
->body($result['message'] ?? 'Unknown error')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function adminConsentUrl(Tenant $tenant): ?string
|
public static function adminConsentUrl(Tenant $tenant): ?string
|
||||||
|
|||||||
@ -5,8 +5,9 @@
|
|||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
class EditTenant extends EditRecord
|
||||||
@ -17,24 +18,21 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
UiEnforcement::forAction(
|
||||||
->tenantFromRecord()
|
Action::make('archive')
|
||||||
->apply(
|
->label('Archive')
|
||||||
Actions\Action::make('archive')
|
->color('danger')
|
||||||
->label('Archive')
|
->requiresConfirmation()
|
||||||
->color('danger')
|
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||||
->requiresConfirmation()
|
->action(function (Tenant $record): void {
|
||||||
->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed())
|
$record->delete();
|
||||||
->action(function (): void {
|
})
|
||||||
$tenant = $this->record;
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
UiEnforcement::for(Capabilities::TENANT_DELETE)
|
->tooltip('You do not have permission to archive tenants.')
|
||||||
->tenantFromRecord()
|
->preserveVisibility()
|
||||||
->authorizeOrAbort($tenant);
|
->destructive()
|
||||||
|
->apply(),
|
||||||
$tenant->delete();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,14 +7,14 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Actions;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class TenantMembershipsRelationManager extends RelationManager
|
class TenantMembershipsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@ -40,185 +40,166 @@ public function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Actions\Action::make('add_member')
|
UiEnforcement::forTableAction(
|
||||||
->label(__('Add member'))
|
Action::make('add_member')
|
||||||
->icon('heroicon-o-plus')
|
->label(__('Add member'))
|
||||||
->visible(function (): bool {
|
->icon('heroicon-o-plus')
|
||||||
$tenant = $this->getOwnerRecord();
|
->form([
|
||||||
|
Forms\Components\Select::make('user_id')
|
||||||
|
->label(__('User'))
|
||||||
|
->required()
|
||||||
|
->searchable()
|
||||||
|
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
||||||
|
Forms\Components\Select::make('role')
|
||||||
|
->label(__('Role'))
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
'owner' => __('Owner'),
|
||||||
|
'manager' => __('Manager'),
|
||||||
|
'operator' => __('Operator'),
|
||||||
|
'readonly' => __('Readonly'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (array $data, TenantMembershipManager $manager): void {
|
||||||
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
$actor = auth()->user();
|
||||||
})
|
if (! $actor instanceof User) {
|
||||||
->form([
|
abort(403);
|
||||||
Forms\Components\Select::make('user_id')
|
}
|
||||||
->label(__('User'))
|
|
||||||
->required()
|
|
||||||
->searchable()
|
|
||||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
|
||||||
Forms\Components\Select::make('role')
|
|
||||||
->label(__('Role'))
|
|
||||||
->required()
|
|
||||||
->options([
|
|
||||||
'owner' => __('Owner'),
|
|
||||||
'manager' => __('Manager'),
|
|
||||||
'operator' => __('Operator'),
|
|
||||||
'readonly' => __('Readonly'),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->action(function (array $data, TenantMembershipManager $manager): void {
|
|
||||||
$tenant = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
$member = User::query()->find((int) $data['user_id']);
|
||||||
abort(404);
|
if (! $member) {
|
||||||
}
|
Notification::make()->title(__('User not found'))->danger()->send();
|
||||||
|
|
||||||
$actor = auth()->user();
|
return;
|
||||||
if (! $actor instanceof User) {
|
}
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
try {
|
||||||
abort(403);
|
$manager->addMember(
|
||||||
}
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
member: $member,
|
||||||
|
role: (string) $data['role'],
|
||||||
|
source: 'manual',
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to add member'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
$member = User::query()->find((int) $data['user_id']);
|
return;
|
||||||
if (! $member) {
|
}
|
||||||
Notification::make()->title(__('User not found'))->danger()->send();
|
|
||||||
|
|
||||||
return;
|
Notification::make()->title(__('Member added'))->success()->send();
|
||||||
}
|
$this->resetTable();
|
||||||
|
}),
|
||||||
try {
|
fn () => $this->getOwnerRecord(),
|
||||||
$manager->addMember(
|
)
|
||||||
tenant: $tenant,
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
actor: $actor,
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
member: $member,
|
->apply(),
|
||||||
role: (string) $data['role'],
|
|
||||||
source: 'manual',
|
|
||||||
);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('Failed to add member'))
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()->title(__('Member added'))->success()->send();
|
|
||||||
$this->resetTable();
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('change_role')
|
UiEnforcement::forTableAction(
|
||||||
->label(__('Change role'))
|
Action::make('change_role')
|
||||||
->icon('heroicon-o-pencil')
|
->label(__('Change role'))
|
||||||
->requiresConfirmation()
|
->icon('heroicon-o-pencil')
|
||||||
->visible(function (): bool {
|
->requiresConfirmation()
|
||||||
$tenant = $this->getOwnerRecord();
|
->form([
|
||||||
|
Forms\Components\Select::make('role')
|
||||||
|
->label(__('Role'))
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
'owner' => __('Owner'),
|
||||||
|
'manager' => __('Manager'),
|
||||||
|
'operator' => __('Operator'),
|
||||||
|
'readonly' => __('Readonly'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
|
||||||
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
$actor = auth()->user();
|
||||||
})
|
if (! $actor instanceof User) {
|
||||||
->form([
|
abort(403);
|
||||||
Forms\Components\Select::make('role')
|
}
|
||||||
->label(__('Role'))
|
|
||||||
->required()
|
|
||||||
->options([
|
|
||||||
'owner' => __('Owner'),
|
|
||||||
'manager' => __('Manager'),
|
|
||||||
'operator' => __('Operator'),
|
|
||||||
'readonly' => __('Readonly'),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
|
|
||||||
$tenant = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
try {
|
||||||
abort(404);
|
$manager->changeRole(
|
||||||
}
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
membership: $record,
|
||||||
|
newRole: (string) $data['role'],
|
||||||
|
);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to change role'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
$actor = auth()->user();
|
return;
|
||||||
if (! $actor instanceof User) {
|
}
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
Notification::make()->title(__('Role updated'))->success()->send();
|
||||||
abort(403);
|
$this->resetTable();
|
||||||
}
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
|
->apply(),
|
||||||
|
|
||||||
try {
|
UiEnforcement::forTableAction(
|
||||||
$manager->changeRole(
|
Action::make('remove')
|
||||||
tenant: $tenant,
|
->label(__('Remove'))
|
||||||
actor: $actor,
|
->color('danger')
|
||||||
membership: $record,
|
->icon('heroicon-o-x-mark')
|
||||||
newRole: (string) $data['role'],
|
->requiresConfirmation()
|
||||||
);
|
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
||||||
} catch (\Throwable $throwable) {
|
$tenant = $this->getOwnerRecord();
|
||||||
Notification::make()
|
|
||||||
->title(__('Failed to change role'))
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
if (! $tenant instanceof Tenant) {
|
||||||
}
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()->title(__('Role updated'))->success()->send();
|
$actor = auth()->user();
|
||||||
$this->resetTable();
|
if (! $actor instanceof User) {
|
||||||
}),
|
abort(403);
|
||||||
Actions\Action::make('remove')
|
}
|
||||||
->label(__('Remove'))
|
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(function (): bool {
|
|
||||||
$tenant = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
try {
|
||||||
return false;
|
$manager->removeMember($tenant, $actor, $record);
|
||||||
}
|
} catch (\Throwable $throwable) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Failed to remove member'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
return;
|
||||||
})
|
}
|
||||||
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
|
||||||
$tenant = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
Notification::make()->title(__('Member removed'))->success()->send();
|
||||||
abort(404);
|
$this->resetTable();
|
||||||
}
|
}),
|
||||||
|
fn () => $this->getOwnerRecord(),
|
||||||
$actor = auth()->user();
|
)
|
||||||
if (! $actor instanceof User) {
|
->requireCapability(Capabilities::TENANT_MEMBERSHIP_MANAGE)
|
||||||
abort(403);
|
->tooltip('You do not have permission to manage tenant memberships.')
|
||||||
}
|
->destructive()
|
||||||
|
->apply(),
|
||||||
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$manager->removeMember($tenant, $actor, $record);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('Failed to remove member'))
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()->title(__('Member removed'))->success()->send();
|
|
||||||
$this->resetTable();
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
class FindingPolicy
|
class FindingPolicy
|
||||||
{
|
{
|
||||||
@ -55,6 +55,9 @@ public function update(User $user, Finding $finding): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_DELETE,
|
Capabilities::TENANT_DELETE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
||||||
@ -40,6 +42,8 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
|
|
||||||
@ -58,6 +62,8 @@ class RoleCapabilityMap
|
|||||||
TenantRole::Operator->value => [
|
TenantRole::Operator->value => [
|
||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
|
|||||||
@ -24,6 +24,12 @@ class Capabilities
|
|||||||
|
|
||||||
public const TENANT_SYNC = 'tenant.sync';
|
public const TENANT_SYNC = 'tenant.sync';
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
||||||
|
|
||||||
|
// Findings
|
||||||
|
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
|
||||||
|
|
||||||
// Tenant memberships
|
// Tenant memberships
|
||||||
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';
|
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';
|
||||||
|
|
||||||
|
|||||||
48
app/Support/Rbac/TenantAccessContext.php
Normal file
48
app/Support/Rbac/TenantAccessContext.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Rbac;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO representing the access context for a tenant-scoped UI action.
|
||||||
|
*
|
||||||
|
* Captures the current user, tenant, membership status, and capability check result
|
||||||
|
* for use by the UiEnforcement helper.
|
||||||
|
*/
|
||||||
|
final readonly class TenantAccessContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?User $user,
|
||||||
|
public ?Tenant $tenant,
|
||||||
|
public bool $isMember,
|
||||||
|
public bool $hasCapability,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-members should receive 404 (deny-as-not-found).
|
||||||
|
*/
|
||||||
|
public function shouldDenyAsNotFound(): bool
|
||||||
|
{
|
||||||
|
return ! $this->isMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Members without capability should receive 403 (forbidden).
|
||||||
|
*/
|
||||||
|
public function shouldDenyAsForbidden(): bool
|
||||||
|
{
|
||||||
|
return $this->isMember && ! $this->hasCapability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User is authorized to perform the action.
|
||||||
|
*/
|
||||||
|
public function isAuthorized(): bool
|
||||||
|
{
|
||||||
|
return $this->isMember && $this->hasCapability;
|
||||||
|
}
|
||||||
|
}
|
||||||
414
app/Support/Rbac/UiEnforcement.php
Normal file
414
app/Support/Rbac/UiEnforcement.php
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Rbac;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Closure;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use ReflectionObject;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central RBAC UI Enforcement Helper for Filament Actions.
|
||||||
|
*
|
||||||
|
* Enforces constitution RBAC-UX rules:
|
||||||
|
* - Non-member → hidden UI + 404 server-side
|
||||||
|
* - Member without capability → visible-but-disabled + tooltip + 403 server-side
|
||||||
|
* - Member with capability → enabled
|
||||||
|
* - Destructive actions → requiresConfirmation()
|
||||||
|
*
|
||||||
|
* @see \App\Support\Rbac\UiTooltips
|
||||||
|
* @see \App\Support\Rbac\TenantAccessContext
|
||||||
|
*/
|
||||||
|
final class UiEnforcement
|
||||||
|
{
|
||||||
|
private Action|BulkAction $action;
|
||||||
|
|
||||||
|
private bool $requireMembership = true;
|
||||||
|
|
||||||
|
private ?string $capability = null;
|
||||||
|
|
||||||
|
private bool $isDestructive = false;
|
||||||
|
|
||||||
|
private ?string $customTooltip = null;
|
||||||
|
|
||||||
|
private Model|Closure|null $record = null;
|
||||||
|
|
||||||
|
private ?Collection $records = null;
|
||||||
|
|
||||||
|
private bool $isBulk = false;
|
||||||
|
|
||||||
|
private bool $preserveExistingVisibility = false;
|
||||||
|
|
||||||
|
private function __construct(Action|BulkAction $action)
|
||||||
|
{
|
||||||
|
$this->action = $action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enforcement for a header/page action.
|
||||||
|
*
|
||||||
|
* @param Action $action The Filament action to wrap
|
||||||
|
*/
|
||||||
|
public static function forAction(Action $action): self
|
||||||
|
{
|
||||||
|
return new self($action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enforcement for a table row action.
|
||||||
|
*
|
||||||
|
* @param Action $action The Filament action to wrap
|
||||||
|
* @param Model|Closure $record The record or a closure that returns the record
|
||||||
|
*/
|
||||||
|
public static function forTableAction(Action $action, Model|Closure $record): self
|
||||||
|
{
|
||||||
|
$instance = new self($action);
|
||||||
|
$instance->record = $record;
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enforcement for a bulk action with all-or-nothing semantics.
|
||||||
|
*
|
||||||
|
* If any selected record fails the capability check for a member,
|
||||||
|
* the action is disabled entirely.
|
||||||
|
*
|
||||||
|
* @param BulkAction $action The Filament bulk action to wrap
|
||||||
|
*/
|
||||||
|
public static function forBulkAction(BulkAction $action): self
|
||||||
|
{
|
||||||
|
$instance = new self($action);
|
||||||
|
$instance->isBulk = true;
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require tenant membership for this action.
|
||||||
|
*
|
||||||
|
* @param bool $require Whether membership is required (default: true)
|
||||||
|
*/
|
||||||
|
public function requireMembership(bool $require = true): self
|
||||||
|
{
|
||||||
|
$this->requireMembership = $require;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a specific capability for this action.
|
||||||
|
*
|
||||||
|
* @param string $capability A capability constant from Capabilities class
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If capability is not in the canonical registry
|
||||||
|
*/
|
||||||
|
public function requireCapability(string $capability): self
|
||||||
|
{
|
||||||
|
if (! Capabilities::isKnown($capability)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
"Unknown capability: {$capability}. Use constants from ".Capabilities::class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->capability = $capability;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark this action as destructive (requires confirmation modal).
|
||||||
|
*/
|
||||||
|
public function destructive(): self
|
||||||
|
{
|
||||||
|
$this->isDestructive = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the default tooltip for disabled actions.
|
||||||
|
*
|
||||||
|
* @param string $message Custom tooltip message
|
||||||
|
*/
|
||||||
|
public function tooltip(string $message): self
|
||||||
|
{
|
||||||
|
$this->customTooltip = $message;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve the action's existing visibility logic.
|
||||||
|
*
|
||||||
|
* Use this when the action already has business-logic visibility
|
||||||
|
* (e.g., `->visible(fn ($record) => $record->trashed())`) that should be kept.
|
||||||
|
*
|
||||||
|
* UiEnforcement will combine the existing visibility condition with tenant
|
||||||
|
* membership visibility, instead of overwriting it.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function preserveVisibility(): self
|
||||||
|
{
|
||||||
|
$this->preserveExistingVisibility = true;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all enforcement rules to the action and return it.
|
||||||
|
*
|
||||||
|
* This sets up:
|
||||||
|
* - UI visibility (hidden for non-members)
|
||||||
|
* - UI disabled state + tooltip (for members without capability)
|
||||||
|
* - Destructive confirmation (if marked)
|
||||||
|
* - Server-side guards (404/403)
|
||||||
|
*
|
||||||
|
* @return Action|BulkAction The configured action
|
||||||
|
*/
|
||||||
|
public function apply(): Action|BulkAction
|
||||||
|
{
|
||||||
|
$this->applyVisibility();
|
||||||
|
$this->applyDisabledState();
|
||||||
|
$this->applyDestructiveConfirmation();
|
||||||
|
$this->applyServerSideGuard();
|
||||||
|
|
||||||
|
return $this->action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide action for non-members.
|
||||||
|
*
|
||||||
|
* Skipped if preserveVisibility() was called.
|
||||||
|
*/
|
||||||
|
private function applyVisibility(): void
|
||||||
|
{
|
||||||
|
if (! $this->requireMembership) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingVisibility = $this->preserveExistingVisibility
|
||||||
|
? $this->getExistingVisibilityCondition()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$this->action->visible(function (?Model $record = null) use ($existingVisibility) {
|
||||||
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
|
if (! $context->isMember) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingVisibility === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->evaluateVisibilityCondition($existingVisibility, $record);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to retrieve the existing visibility condition from the action.
|
||||||
|
*
|
||||||
|
* Filament stores this as the protected property `$isVisible` (bool|Closure)
|
||||||
|
* on actions via the CanBeHidden concern.
|
||||||
|
*/
|
||||||
|
private function getExistingVisibilityCondition(): bool|Closure|null
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$ref = new ReflectionObject($this->action);
|
||||||
|
if (! $ref->hasProperty('isVisible')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$property = $ref->getProperty('isVisible');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
|
||||||
|
/** @var bool|Closure $value */
|
||||||
|
$value = $property->getValue($this->action);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate an existing bool|Closure visibility condition.
|
||||||
|
*
|
||||||
|
* This is a best-effort evaluator for business visibility closures.
|
||||||
|
* If the closure cannot be evaluated safely, we fail closed (return false).
|
||||||
|
*/
|
||||||
|
private function evaluateVisibilityCondition(bool|Closure $condition, ?Model $record): bool
|
||||||
|
{
|
||||||
|
if (is_bool($condition)) {
|
||||||
|
return $condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflection = new \ReflectionFunction($condition);
|
||||||
|
$parameters = $reflection->getParameters();
|
||||||
|
|
||||||
|
if ($parameters === []) {
|
||||||
|
return (bool) $condition();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $condition($record);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable action for members without capability.
|
||||||
|
*/
|
||||||
|
private function applyDisabledState(): void
|
||||||
|
{
|
||||||
|
if ($this->capability === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
|
|
||||||
|
$this->action->disabled(function (?Model $record = null) {
|
||||||
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
|
// Non-members are hidden, so this only affects members
|
||||||
|
if (! $context->isMember) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $context->hasCapability;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only show tooltip when actually disabled
|
||||||
|
$this->action->tooltip(function (?Model $record = null) use ($tooltip) {
|
||||||
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
|
if ($context->isMember && ! $context->hasCapability) {
|
||||||
|
return $tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add confirmation modal for destructive actions.
|
||||||
|
*/
|
||||||
|
private function applyDestructiveConfirmation(): void
|
||||||
|
{
|
||||||
|
if (! $this->isDestructive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->action->requiresConfirmation();
|
||||||
|
$this->action->modalHeading(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE);
|
||||||
|
$this->action->modalDescription(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap the action handler with server-side authorization guard.
|
||||||
|
*
|
||||||
|
* This is a defense-in-depth measure. In normal operation, Filament's
|
||||||
|
* isDisabled() check prevents execution. This guard catches edge cases
|
||||||
|
* where the disabled check might be bypassed.
|
||||||
|
*/
|
||||||
|
private function applyServerSideGuard(): void
|
||||||
|
{
|
||||||
|
$this->action->before(function (?Model $record = null): void {
|
||||||
|
$context = $this->resolveContextWithRecord($record);
|
||||||
|
|
||||||
|
// Non-member → 404 (deny-as-not-found)
|
||||||
|
if ($context->shouldDenyAsNotFound()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member without capability → 403 (forbidden)
|
||||||
|
if ($context->shouldDenyAsForbidden()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current access context with an optional record.
|
||||||
|
*/
|
||||||
|
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
// For table actions, resolve the record and use it as tenant if it's a Tenant
|
||||||
|
$tenant = $this->resolveTenantWithRecord($record);
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return new TenantAccessContext(
|
||||||
|
user: null,
|
||||||
|
tenant: null,
|
||||||
|
isMember: false,
|
||||||
|
hasCapability: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$isMember = $resolver->isMember($user, $tenant);
|
||||||
|
|
||||||
|
$hasCapability = true;
|
||||||
|
if ($this->capability !== null && $isMember) {
|
||||||
|
$hasCapability = $resolver->can($user, $tenant, $this->capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TenantAccessContext(
|
||||||
|
user: $user,
|
||||||
|
tenant: $tenant,
|
||||||
|
isMember: $isMember,
|
||||||
|
hasCapability: $hasCapability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the tenant for this action with an optional record.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. If $record is passed and is a Tenant, use it
|
||||||
|
* 2. If $this->record is set (for forTableAction), resolve it
|
||||||
|
* 3. Fall back to Filament::getTenant()
|
||||||
|
*/
|
||||||
|
private function resolveTenantWithRecord(?Model $record = null): ?Tenant
|
||||||
|
{
|
||||||
|
// If a record is passed directly (from closure parameter), check if it's a Tenant
|
||||||
|
if ($record instanceof Tenant) {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a record is set from forTableAction, try to resolve it
|
||||||
|
if ($this->record !== null) {
|
||||||
|
$resolved = $this->record instanceof Closure
|
||||||
|
? ($this->record)()
|
||||||
|
: $this->record;
|
||||||
|
|
||||||
|
if ($resolved instanceof Tenant) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: use Filament's current tenant
|
||||||
|
return Filament::getTenant();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Support/Rbac/UiTooltips.php
Normal file
33
app/Support/Rbac/UiTooltips.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Rbac;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized tooltip and confirmation messages for RBAC UI enforcement.
|
||||||
|
*
|
||||||
|
* These constants provide consistent, non-leaky messaging for:
|
||||||
|
* - Permission denials (members lacking capability)
|
||||||
|
* - Destructive action confirmations
|
||||||
|
*
|
||||||
|
* @see \App\Support\Rbac\UiEnforcement
|
||||||
|
*/
|
||||||
|
final class UiTooltips
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Tooltip shown when a member lacks the required capability.
|
||||||
|
* Intentionally vague to avoid leaking permission structure.
|
||||||
|
*/
|
||||||
|
public const INSUFFICIENT_PERMISSION = 'You don\'t have permission to do this. Ask a tenant admin.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal heading for destructive action confirmation.
|
||||||
|
*/
|
||||||
|
public const DESTRUCTIVE_CONFIRM_TITLE = 'Are you sure?';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal description for destructive action confirmation.
|
||||||
|
*/
|
||||||
|
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
|
||||||
|
}
|
||||||
@ -18,17 +18,16 @@
|
|||||||
'status' => Finding::STATUS_NEW,
|
'status' => Finding::STATUS_NEW,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$thrown = null;
|
$component = Livewire::test(ListFindings::class)
|
||||||
|
->assertTableBulkActionVisible('acknowledge_selected')
|
||||||
|
->assertTableBulkActionDisabled('acknowledge_selected');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Livewire::test(ListFindings::class)
|
$component->callTableBulkAction('acknowledge_selected', $findings);
|
||||||
->callTableBulkAction('acknowledge_selected', $findings);
|
} catch (Throwable) {
|
||||||
} catch (Throwable $exception) {
|
// Filament actions may abort/throw when forced to execute.
|
||||||
$thrown = $exception;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect($thrown)->not->toBeNull();
|
|
||||||
|
|
||||||
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -45,16 +44,15 @@
|
|||||||
'status' => Finding::STATUS_NEW,
|
'status' => Finding::STATUS_NEW,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$thrown = null;
|
$component = Livewire::test(ListFindings::class)
|
||||||
|
->assertActionVisible('acknowledge_all_matching')
|
||||||
|
->assertActionDisabled('acknowledge_all_matching');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Livewire::test(ListFindings::class)
|
$component->callAction('acknowledge_all_matching');
|
||||||
->callAction('acknowledge_all_matching');
|
} catch (Throwable) {
|
||||||
} catch (Throwable $exception) {
|
// Filament actions may abort/throw when forced to execute.
|
||||||
$thrown = $exception;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect($thrown)->not->toBeNull();
|
|
||||||
|
|
||||||
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|||||||
@ -74,9 +74,12 @@
|
|||||||
'ownerRecord' => $tenant,
|
'ownerRecord' => $tenant,
|
||||||
'pageClass' => ViewTenant::class,
|
'pageClass' => ViewTenant::class,
|
||||||
])
|
])
|
||||||
->assertTableActionHidden('add_member')
|
->assertTableActionVisible('add_member')
|
||||||
->assertTableActionHidden('change_role', $membership)
|
->assertTableActionDisabled('add_member')
|
||||||
->assertTableActionHidden('remove', $membership);
|
->assertTableActionVisible('change_role', $membership)
|
||||||
|
->assertTableActionDisabled('change_role', $membership)
|
||||||
|
->assertTableActionVisible('remove', $membership)
|
||||||
|
->assertTableActionDisabled('remove', $membership);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents removing or demoting the last owner', function (): void {
|
it('prevents removing or demoting the last owner', function (): void {
|
||||||
|
|||||||
@ -161,7 +161,7 @@
|
|||||||
|
|
||||||
Livewire::test(ListInventoryItems::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
||||||
->assertStatus(403);
|
->assertSuccessful();
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
|
|||||||
@ -46,9 +46,7 @@
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk();
|
||||||
->assertDontSee('Update credentials')
|
|
||||||
->assertDontSee('Disable connection');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readonly users can view provider connections but cannot manage them', function () {
|
test('readonly users can view provider connections but cannot manage them', function () {
|
||||||
@ -69,9 +67,7 @@
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk();
|
||||||
->assertDontSee('Update credentials')
|
|
||||||
->assertDontSee('Disable connection');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('provider connection edit is not accessible cross-tenant', function () {
|
test('provider connection edit is not accessible cross-tenant', function () {
|
||||||
|
|||||||
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
|
||||||
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('Backup items relation manager UI enforcement', function () {
|
||||||
|
it('shows add policies as visible but disabled for readonly members', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Test backup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$item = BackupItem::factory()->for($backupSet)->for($tenant)->create();
|
||||||
|
|
||||||
|
Livewire::test(BackupItemsRelationManager::class, [
|
||||||
|
'ownerRecord' => $backupSet,
|
||||||
|
'pageClass' => EditBackupSet::class,
|
||||||
|
])
|
||||||
|
->assertTableActionVisible('addPolicies')
|
||||||
|
->assertTableActionDisabled('addPolicies')
|
||||||
|
->assertTableActionExists('addPolicies', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === 'You do not have permission to add policies.';
|
||||||
|
})
|
||||||
|
->assertTableBulkActionVisible('bulk_remove')
|
||||||
|
->assertTableBulkActionDisabled('bulk_remove', [$item]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows add policies as enabled for owner members', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Test backup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$item = BackupItem::factory()->for($backupSet)->for($tenant)->create();
|
||||||
|
|
||||||
|
Livewire::test(BackupItemsRelationManager::class, [
|
||||||
|
'ownerRecord' => $backupSet,
|
||||||
|
'pageClass' => EditBackupSet::class,
|
||||||
|
])
|
||||||
|
->assertTableActionVisible('addPolicies')
|
||||||
|
->assertTableActionEnabled('addPolicies')
|
||||||
|
->assertTableBulkActionVisible('bulk_remove')
|
||||||
|
->assertTableBulkActionEnabled('bulk_remove', [$item]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides actions after membership is revoked mid-session', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Test backup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
BackupItem::factory()->for($backupSet)->for($tenant)->create();
|
||||||
|
|
||||||
|
$component = Livewire::test(BackupItemsRelationManager::class, [
|
||||||
|
'ownerRecord' => $backupSet,
|
||||||
|
'pageClass' => EditBackupSet::class,
|
||||||
|
])
|
||||||
|
->assertTableActionVisible('addPolicies')
|
||||||
|
->assertTableActionEnabled('addPolicies')
|
||||||
|
->assertTableBulkActionVisible('bulk_remove');
|
||||||
|
|
||||||
|
$user->tenants()->detach($tenant->getKey());
|
||||||
|
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->call('$refresh')
|
||||||
|
->assertTableActionHidden('addPolicies')
|
||||||
|
->assertTableBulkActionHidden('bulk_remove');
|
||||||
|
});
|
||||||
|
});
|
||||||
37
tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
Normal file
37
tests/Feature/Rbac/CreateRestoreRunAuthorizationTest.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('Create restore run page authorization', function () {
|
||||||
|
it('returns 404 for non-members (deny as not found)', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->assertStatus(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 for members without tenant manage capability', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->assertStatus(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
tests/Feature/Rbac/DriftLandingUiEnforcementTest.php
Normal file
65
tests/Feature/Rbac/DriftLandingUiEnforcementTest.php
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DriftLanding;
|
||||||
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
describe('Drift landing generate permission', function () {
|
||||||
|
it('blocks generation for readonly members (no tenant sync)', function () {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
InventorySyncRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventorySyncRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(DriftLanding::class)
|
||||||
|
->assertSet('state', 'blocked')
|
||||||
|
->assertSet('message', 'You can view existing drift findings and run history, but you do not have permission to generate drift.');
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(GenerateDriftFindingsJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts generation for owner members (tenant sync allowed)', function () {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
InventorySyncRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$latestRun = InventorySyncRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(DriftLanding::class)
|
||||||
|
->assertSet('state', 'generating')
|
||||||
|
->assertSet('scopeKey', (string) $latestRun->selection_hash);
|
||||||
|
|
||||||
|
$operationRunId = $component->get('operationRunId');
|
||||||
|
expect($operationRunId)->toBeInt()->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
Bus::assertDispatched(GenerateDriftFindingsJob::class);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
describe('Edit provider connection actions UI enforcement', function () {
|
||||||
|
it('shows enable connection action as visible but disabled for readonly members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => 'disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
|
||||||
|
->assertActionVisible('enable_connection')
|
||||||
|
->assertActionDisabled('enable_connection')
|
||||||
|
->assertActionExists('enable_connection', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === 'You do not have permission to manage provider connections.';
|
||||||
|
})
|
||||||
|
->mountAction('enable_connection')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$connection->refresh();
|
||||||
|
expect($connection->status)->toBe('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows disable connection action as visible but disabled for readonly members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
|
||||||
|
->assertActionVisible('disable_connection')
|
||||||
|
->assertActionDisabled('disable_connection')
|
||||||
|
->assertActionExists('disable_connection', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === 'You do not have permission to manage provider connections.';
|
||||||
|
})
|
||||||
|
->mountAction('disable_connection')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$connection->refresh();
|
||||||
|
expect($connection->status)->toBe('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows enable connection action as enabled for owner members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => 'disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
|
||||||
|
->assertActionVisible('enable_connection')
|
||||||
|
->assertActionEnabled('enable_connection');
|
||||||
|
});
|
||||||
|
});
|
||||||
54
tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
Normal file
54
tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
describe('Edit tenant archive action UI enforcement', function () {
|
||||||
|
it('shows archive action as visible but disabled for manager members', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionDisabled('archive')
|
||||||
|
->assertActionExists('archive', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === 'You do not have permission to archive tenants.';
|
||||||
|
})
|
||||||
|
->mountAction('archive')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$tenant->refresh();
|
||||||
|
expect($tenant->trashed())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows owner members to archive tenant', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionEnabled('archive')
|
||||||
|
->mountAction('archive')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$tenant->refresh();
|
||||||
|
expect($tenant->trashed())->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php
Normal file
66
tests/Feature/Rbac/EntraGroupSyncRunsUiEnforcementTest.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\EntraGroupSyncRunResource\Pages\ListEntraGroupSyncRuns;
|
||||||
|
use App\Jobs\EntraGroupSyncJob;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
describe('Entra group sync runs UI enforcement', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Queue::fake();
|
||||||
|
Notification::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sync action for non-members', function () {
|
||||||
|
// Mount as a valid tenant member first, then revoke membership mid-session.
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListEntraGroupSyncRuns::class)
|
||||||
|
->assertActionVisible('sync_groups');
|
||||||
|
|
||||||
|
$user->tenants()->detach($tenant->getKey());
|
||||||
|
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
$component->assertActionHidden('sync_groups');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sync action as visible but disabled for readonly members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListEntraGroupSyncRuns::class)
|
||||||
|
->assertActionVisible('sync_groups')
|
||||||
|
->assertActionDisabled('sync_groups');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows owner members to execute sync action (dispatches job)', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListEntraGroupSyncRuns::class)
|
||||||
|
->assertActionVisible('sync_groups')
|
||||||
|
->assertActionEnabled('sync_groups')
|
||||||
|
->mountAction('sync_groups')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
Queue::assertPushed(EntraGroupSyncJob::class);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
describe('Inventory item resource authorization', function () {
|
||||||
|
it('is not visible for non-members', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
expect(InventoryItemResource::canViewAny())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is visible for readonly members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
expect(InventoryItemResource::canViewAny())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents viewing inventory items from other tenants', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$record = InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $otherTenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(InventoryItemResource::canView($record))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows viewing inventory items from the current tenant', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$record = InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(InventoryItemResource::canView($record))->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
|
||||||
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('Policy versions relation manager restore-to-Intune UI enforcement', function () {
|
||||||
|
it('disables restore action for readonly members', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(VersionsRelationManager::class, [
|
||||||
|
'ownerRecord' => $policy,
|
||||||
|
'pageClass' => ViewPolicy::class,
|
||||||
|
])
|
||||||
|
->assertTableActionDisabled('restore_to_intune', $version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables restore action for metadata-only snapshots', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'metadata' => ['source' => 'metadata_only'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(VersionsRelationManager::class, [
|
||||||
|
'ownerRecord' => $policy,
|
||||||
|
'pageClass' => ViewPolicy::class,
|
||||||
|
])
|
||||||
|
->assertTableActionDisabled('restore_to_intune', $version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides restore action after membership is revoked mid-session', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(VersionsRelationManager::class, [
|
||||||
|
'ownerRecord' => $policy,
|
||||||
|
'pageClass' => ViewPolicy::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->detach($tenant->getKey());
|
||||||
|
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->call('$refresh')
|
||||||
|
->assertTableActionHidden('restore_to_intune', $version);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
describe('Provider connections create action UI enforcement', function () {
|
||||||
|
it('shows create action as visible but disabled for readonly members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListProviderConnections::class)
|
||||||
|
->assertActionVisible('create')
|
||||||
|
->assertActionDisabled('create')
|
||||||
|
->assertActionExists('create', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === 'You do not have permission to create provider connections.';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows create action as enabled for owner members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListProviderConnections::class)
|
||||||
|
->assertActionVisible('create')
|
||||||
|
->assertActionEnabled('create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides create action after membership is revoked mid-session', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListProviderConnections::class)
|
||||||
|
->assertActionVisible('create')
|
||||||
|
->assertActionEnabled('create');
|
||||||
|
|
||||||
|
$user->tenants()->detach($tenant->getKey());
|
||||||
|
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->call('$refresh')
|
||||||
|
->assertActionHidden('create');
|
||||||
|
});
|
||||||
|
});
|
||||||
23
tests/Feature/Rbac/RegisterTenantAuthorizationTest.php
Normal file
23
tests/Feature/Rbac/RegisterTenantAuthorizationTest.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\Tenancy\RegisterTenant;
|
||||||
|
|
||||||
|
describe('Register tenant page authorization', function () {
|
||||||
|
it('is not visible for readonly members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
expect(RegisterTenant::canView())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is visible for owner members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
expect(RegisterTenant::canView())->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
|
||||||
|
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
|
||||||
|
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
|
||||||
|
|
||||||
expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue();
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
|
||||||
|
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
|
||||||
|
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
|
||||||
|
|
||||||
expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue();
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
|
||||||
|
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
|
||||||
|
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue();
|
||||||
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue();
|
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue();
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse();
|
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse();
|
||||||
|
|
||||||
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse();
|
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse();
|
||||||
|
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse();
|
||||||
|
expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeFalse();
|
||||||
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse();
|
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse();
|
||||||
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse();
|
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('Tenant memberships relation manager UI enforcement', function () {
|
||||||
|
it('shows membership actions as visible but disabled for manager members', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $otherUser, role: 'readonly');
|
||||||
|
|
||||||
|
Livewire::test(TenantMembershipsRelationManager::class, [
|
||||||
|
'ownerRecord' => $tenant,
|
||||||
|
'pageClass' => EditTenant::class,
|
||||||
|
])
|
||||||
|
->assertTableActionVisible('add_member')
|
||||||
|
->assertTableActionDisabled('add_member')
|
||||||
|
->assertTableActionExists('add_member', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === 'You do not have permission to manage tenant memberships.';
|
||||||
|
})
|
||||||
|
->assertTableActionVisible('change_role')
|
||||||
|
->assertTableActionDisabled('change_role')
|
||||||
|
->assertTableActionExists('change_role', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === 'You do not have permission to manage tenant memberships.';
|
||||||
|
})
|
||||||
|
->assertTableActionVisible('remove')
|
||||||
|
->assertTableActionDisabled('remove')
|
||||||
|
->assertTableActionExists('remove', function (Action $action): bool {
|
||||||
|
return $action->getTooltip() === 'You do not have permission to manage tenant memberships.';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
58
tests/Feature/Rbac/TenantResourceAuthorizationTest.php
Normal file
58
tests/Feature/Rbac/TenantResourceAuthorizationTest.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
describe('Tenant resource authorization', function () {
|
||||||
|
it('cannot be created by non-members', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
expect(TenantResource::canCreate())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be created by managers (TENANT_MANAGE)', function () {
|
||||||
|
[$user] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
expect(TenantResource::canCreate())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be edited by managers (TENANT_MANAGE)', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
expect(TenantResource::canEdit($tenant))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot be deleted by managers (TENANT_DELETE)', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
expect(TenantResource::canDelete($tenant))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be deleted by owners (TENANT_DELETE)', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
expect(TenantResource::canDelete($tenant))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot edit tenants it cannot access', function () {
|
||||||
|
[$user] = createUserWithTenant(role: 'manager');
|
||||||
|
$otherTenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
expect(TenantResource::canEdit($otherTenant))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
59
tests/Feature/Rbac/UiEnforcementDestructiveTest.php
Normal file
59
tests/Feature/Rbac/UiEnforcementDestructiveTest.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for destructive action behavior in UiEnforcement
|
||||||
|
*
|
||||||
|
* These tests verify that:
|
||||||
|
* - Destructive actions are configured with confirmation modal
|
||||||
|
* - Modal heading/description are set correctly
|
||||||
|
* - Action only executes after confirmation
|
||||||
|
*/
|
||||||
|
describe('Destructive actions require confirmation', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Queue::fake();
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts sync action for modal confirmation', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
// mountAction shows the confirmation modal
|
||||||
|
// assertActionMounted confirms it was mounted (awaiting confirmation)
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionVisible('sync')
|
||||||
|
->assertActionEnabled('sync')
|
||||||
|
->mountAction('sync')
|
||||||
|
->assertActionMounted('sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not execute destructive action without calling confirm', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
// Mount but don't call - verify no side effects
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->mountAction('sync');
|
||||||
|
|
||||||
|
// No job should be dispatched yet
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has confirmation modal configured with correct title', function () {
|
||||||
|
// Verify UiTooltips constants are set correctly
|
||||||
|
expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->toBe('Are you sure?');
|
||||||
|
expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->toBe('This action cannot be undone.');
|
||||||
|
});
|
||||||
|
});
|
||||||
92
tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php
Normal file
92
tests/Feature/Rbac/UiEnforcementMemberDisabledTest.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
|
use App\Jobs\SyncPoliciesJob;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for US1: Tenant member sees consistent disabled UX
|
||||||
|
*
|
||||||
|
* These tests verify that UiEnforcement correctly handles:
|
||||||
|
* - Members WITH capability → action enabled, can execute
|
||||||
|
* - Members WITHOUT capability → action visible but disabled with tooltip, cannot execute
|
||||||
|
*
|
||||||
|
* Note: In Filament v5, disabled actions don't throw 403 - they silently fail.
|
||||||
|
* The server-side guard is a defense-in-depth measure that only triggers if
|
||||||
|
* somehow the disabled check is bypassed.
|
||||||
|
*/
|
||||||
|
describe('US1: Member without capability sees disabled action + tooltip', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Queue::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sync action as visible but disabled for readonly members', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionVisible('sync')
|
||||||
|
->assertActionDisabled('sync');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not execute sync action for readonly members (silently blocked by Filament)', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
// When a disabled action is called, Filament blocks it silently (200 response, no execution)
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->mountAction('sync')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
// The action should NOT have executed
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('US1: Member with capability sees enabled action + can execute', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Queue::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sync action as enabled for owner members', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionVisible('sync')
|
||||||
|
->assertActionEnabled('sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows owner members to execute sync action successfully', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->mountAction('sync')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncPoliciesJob::class);
|
||||||
|
});
|
||||||
|
});
|
||||||
152
tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php
Normal file
152
tests/Feature/Rbac/UiEnforcementNonMemberHiddenTest.php
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for US2: Non-members cannot infer tenant resources
|
||||||
|
*
|
||||||
|
* These tests verify that UiEnforcement correctly handles:
|
||||||
|
* - Non-members → action hidden in UI (prevents discovery)
|
||||||
|
* - Non-members → action blocked from execution (no side effects)
|
||||||
|
* - Membership revoked mid-session → still enforces protection
|
||||||
|
*
|
||||||
|
* Note on 404 behavior:
|
||||||
|
* In Filament v5, hidden actions are treated as disabled and return 200 (no execution)
|
||||||
|
* rather than 404. This is because Filament's action system doesn't support custom
|
||||||
|
* HTTP status codes for blocked actions. The security guarantee is:
|
||||||
|
* - Non-members cannot discover actions (hidden in UI)
|
||||||
|
* - Non-members cannot execute actions (blocked by Filament's isHidden check)
|
||||||
|
* - No side effects occur (jobs not pushed, data not modified)
|
||||||
|
*
|
||||||
|
* True 404 enforcement happens at the page/routing level via tenant middleware.
|
||||||
|
*/
|
||||||
|
describe('US2: Non-member sees action hidden in UI', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Queue::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sync action for users who are not members of the tenant', function () {
|
||||||
|
// Create user without membership to the tenant
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
// No membership created
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionHidden('sync');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sync action for authenticated users accessing wrong tenant', function () {
|
||||||
|
// User is member of tenantA but accessing tenantB
|
||||||
|
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
||||||
|
$tenantB = Tenant::factory()->create();
|
||||||
|
// User has no membership to tenantB
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenantB->makeCurrent();
|
||||||
|
Filament::setTenant($tenantB, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionHidden('sync');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('US2: Non-member action execution is blocked', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Queue::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks action execution for non-members (no side effects)', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
// No membership
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
// Hidden actions are treated as disabled by Filament
|
||||||
|
// The action call returns 200 but no execution occurs
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->mountAction('sync')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
// Verify no side effects
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('US2: Membership revoked mid-session still enforces protection', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Queue::fake();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks action execution when membership is revoked between page load and action click', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
// Start the test - action should be visible for member
|
||||||
|
$component = Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionVisible('sync')
|
||||||
|
->assertActionEnabled('sync');
|
||||||
|
|
||||||
|
// Simulate membership revocation mid-session
|
||||||
|
$user->tenants()->detach($tenant->getKey());
|
||||||
|
|
||||||
|
// Clear capability cache to ensure fresh check
|
||||||
|
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
// Now try to execute - action is now hidden (via fresh isVisible evaluation)
|
||||||
|
// Filament blocks execution (returns 200 but no side effects)
|
||||||
|
$component
|
||||||
|
->mountAction('sync')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
// Verify no side effects
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides action in UI after membership revocation on re-render', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
// Initial state - action visible
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionVisible('sync');
|
||||||
|
|
||||||
|
// Revoke membership
|
||||||
|
$user->tenants()->detach($tenant->getKey());
|
||||||
|
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
// New component instance (simulates page refresh)
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionHidden('sync');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
Livewire::test(ListInventoryItems::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
|
||||||
->assertStatus(403);
|
->assertSuccessful();
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
|
|||||||
84
tests/Unit/Support/Rbac/UiEnforcementTest.php
Normal file
84
tests/Unit/Support/Rbac/UiEnforcementTest.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\TenantAccessContext;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
|
||||||
|
describe('TenantAccessContext', function () {
|
||||||
|
it('correctly identifies non-member as deny-as-not-found', function () {
|
||||||
|
$context = new TenantAccessContext(
|
||||||
|
user: User::factory()->make(),
|
||||||
|
tenant: Tenant::factory()->make(),
|
||||||
|
isMember: false,
|
||||||
|
hasCapability: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($context->shouldDenyAsNotFound())->toBeTrue();
|
||||||
|
expect($context->shouldDenyAsForbidden())->toBeFalse();
|
||||||
|
expect($context->isAuthorized())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly identifies member without capability as forbidden', function () {
|
||||||
|
$context = new TenantAccessContext(
|
||||||
|
user: User::factory()->make(),
|
||||||
|
tenant: Tenant::factory()->make(),
|
||||||
|
isMember: true,
|
||||||
|
hasCapability: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($context->shouldDenyAsNotFound())->toBeFalse();
|
||||||
|
expect($context->shouldDenyAsForbidden())->toBeTrue();
|
||||||
|
expect($context->isAuthorized())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly identifies authorized member', function () {
|
||||||
|
$context = new TenantAccessContext(
|
||||||
|
user: User::factory()->make(),
|
||||||
|
tenant: Tenant::factory()->make(),
|
||||||
|
isMember: true,
|
||||||
|
hasCapability: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($context->shouldDenyAsNotFound())->toBeFalse();
|
||||||
|
expect($context->shouldDenyAsForbidden())->toBeFalse();
|
||||||
|
expect($context->isAuthorized())->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UiTooltips', function () {
|
||||||
|
it('has non-empty insufficient permission message', function () {
|
||||||
|
expect(UiTooltips::INSUFFICIENT_PERMISSION)->toBeString();
|
||||||
|
expect(UiTooltips::INSUFFICIENT_PERMISSION)->not->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has non-empty destructive confirmation messages', function () {
|
||||||
|
expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->toBeString();
|
||||||
|
expect(UiTooltips::DESTRUCTIVE_CONFIRM_TITLE)->not->toBeEmpty();
|
||||||
|
expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->toBeString();
|
||||||
|
expect(UiTooltips::DESTRUCTIVE_CONFIRM_DESCRIPTION)->not->toBeEmpty();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UiEnforcement', function () {
|
||||||
|
it('throws when unknown capability is passed', function () {
|
||||||
|
$action = \Filament\Actions\Action::make('test')
|
||||||
|
->action(fn () => null);
|
||||||
|
|
||||||
|
expect(fn () => UiEnforcement::forAction($action)
|
||||||
|
->requireCapability('unknown.capability')
|
||||||
|
)->toThrow(\InvalidArgumentException::class, 'Unknown capability');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts known capabilities from registry', function () {
|
||||||
|
$action = \Filament\Actions\Action::make('test')
|
||||||
|
->action(fn () => null);
|
||||||
|
|
||||||
|
$enforcement = UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::PROVIDER_MANAGE);
|
||||||
|
|
||||||
|
expect($enforcement)->toBeInstanceOf(UiEnforcement::class);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user