diff --git a/.gitignore b/.gitignore index b71b1ea..1b59610 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ Homestead.json Homestead.yaml Thumbs.db +/references \ No newline at end of file diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a4670ff..a4438da 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,50 +1,175 @@ -# [PROJECT_NAME] Constitution - + + +# TenantPilot Constitution ## Core Principles -### [PRINCIPLE_1_NAME] - -[PRINCIPLE_1_DESCRIPTION] - +### I. Safety-First Operations -### [PRINCIPLE_2_NAME] - -[PRINCIPLE_2_DESCRIPTION] - +Every destructive or high-impact action involving Intune configurations MUST implement safety mechanisms: +- **Explicit confirmation UI** for restore, rollback, and destructive operations +- **Dry-run/preview modes** where technically feasible, showing clear change summaries before execution +- **Validation gates** detecting conflicts, incompatible states, or invalid inputs +- **Audit log entries** for all critical operations (backup creation, restore execution, policy rollback) -### [PRINCIPLE_3_NAME] - -[PRINCIPLE_3_DESCRIPTION] - +**Rationale**: Intune configurations are critical production assets. A single misconfigured policy can affect thousands of devices. Safety gates prevent operational accidents and provide recovery visibility. -### [PRINCIPLE_4_NAME] - -[PRINCIPLE_4_DESCRIPTION] - +### II. Immutable Versioning -### [PRINCIPLE_5_NAME] - -[PRINCIPLE_5_DESCRIPTION] - +Policy versions MUST be stored as immutable snapshots: +- **Full payload capture** in JSONB with metadata (who, when, source tenant, policy type) +- **No in-place modifications** to version records after creation +- **Queryable history** by policy, time range, and actor +- **Diff capabilities** between any two versions (human-readable summary + structured JSON where feasible) -## [SECTION_2_NAME] - +**Rationale**: Immutable versions provide reliable rollback targets, accurate audit trails, and trustworthy diff outputs. Mutable history undermines auditability and introduces rollback risks. -[SECTION_2_CONTENT] - +### III. Defensive Restore -## [SECTION_3_NAME] - +Restore operations MUST be defensive and transparent: +- **Preview/dry-run mode** showing what changes will be applied before execution +- **Selective restore** allowing granular control over which items to restore +- **Conflict detection** flagging existing resources that may be overwritten or incompatible +- **Pre-execution summary** clearly communicating scope, risks, and affected resources +- **Explicit confirmation** required before executing any restore -[SECTION_3_CONTENT] - +**Rationale**: Restore operations can overwrite production configurations. Defensive workflows reduce risk of unintended changes and provide administrators with informed control. + +### IV. Auditability + +All critical operations MUST produce comprehensive audit logs: +- **Scope**: backup creation, restore execution/attempts, policy rollback, policy version creation +- **Content**: actor, timestamp, operation type, affected resources, outcome (success/failure/partial) +- **Tenant-scoped**: logs MUST be queryable per tenant for multi-tenant future support +- **RBAC-respecting**: log access follows same permission model as operational access +- **High-level Graph logging**: log Graph API calls without exposing secrets or sensitive payloads + +**Rationale**: Audit trails enable compliance verification, incident investigation, and operational transparency. They are non-negotiable for enterprise configuration management. + +### V. Tenant-Aware Architecture + +Data models and business logic MUST be tenant-scoped from day one: +- **Tenant entity** present in schema even if v1 deploys single-tenant +- **Foreign key relationships** reference tenant_id where applicable +- **Service layer logic** accepts tenant context explicitly +- **Isolation enforcement** ensures no cross-tenant data leakage in queries or operations + +**Rationale**: TenantPilot v1 is single-tenant per deployment, but the architecture must support multi-tenant evolution. Retrofitting tenant isolation later is expensive and error-prone. + +### VI. Graph Abstraction + +Microsoft Graph integration MUST be isolated behind a dedicated abstraction layer: +- **Domain services** depend on `GraphClient` interface, not raw Graph SDK +- **Responsibilities**: auth token handling, rate-limit-friendly batching, standardized error mapping +- **No direct Graph calls** in controllers, Filament resources, or domain logic +- **Testability**: Graph layer must be mockable for integration tests + +**Rationale**: Graph API complexity (auth, rate limits, versioning, error handling) should not bleed into domain logic. Abstraction enables cleaner testing, easier SDK upgrades, and centralized rate limit management. + +### VII. Spec-Driven Development + +All features MUST follow the Spec Kit workflow: +1. **Read** `.specify/constitution.md` (this document) +2. **Create/update** `.specify/spec.md` defining user stories, acceptance criteria, and requirements +3. **Produce** `.specify/plan.md` with technical design, structure decisions, and constitution check +4. **Break down** into `.specify/tasks.md` organized by independently testable user stories +5. **Implement** in small, reviewable PRs aligned with tasks + +**Non-negotiable constraints**: +- **Constitution check** in plan.md MUST pass before implementation +- **User stories** in spec.md MUST be prioritized and independently testable +- **Requirements changes** during implementation MUST update spec/plan before continuing +- **Tasks** MUST be organized by user story to enable incremental delivery + +**Rationale**: Spec Kit enforces thoughtful design, prevents scope drift, and maintains alignment between requirements, design, and implementation. It provides checkpoints for validation before costly implementation work. + +## Security & Permissions + +- **Least privilege**: Graph scopes and app permissions MUST request only necessary access +- **Role-based access control**: Admin vs. read-only auditor roles (v1 baseline) +- **No secrets in code**: Use Laravel encrypted storage or environment-based secret management +- **Tenant identifier validation**: All Graph operations MUST validate tenant context +- **Encrypted storage**: Sensitive fields (tokens, credentials) MUST use Laravel encryption where stored + +## Technology Stack + +**Core Stack**: +- Backend: **Laravel** (latest stable) +- Admin UI: **Filament** (v3+) +- Database: **PostgreSQL** (via Sail locally, managed service in production) +- Auth: **Microsoft Identity** (Entra ID/Azure AD integration) +- External API: **Microsoft Graph** (Intune endpoints) + +**Development Environment**: +- Local: **Laravel Sail** (Docker-based, PostgreSQL container) +- Tooling: **Drizzle** for local PostgreSQL workflows (if configured) +- Testing: **Pest** (PHPUnit-based) + +**Deployment**: +- Repository: **Gitea** (self-hosted) +- Deployment: **Dokploy** on VPS (container-based) +- Environments: **Staging** (mandatory validation gate) → **Production** + +**Constraints**: +- PHP: **PSR-12** conventions +- Migrations: **Reversible**, validated on Staging before Production +- JSONB storage: Use for raw Graph payloads, policy snapshots, backup items +- Indexing: **GIN indexes** on JSONB fields requiring search/filter ## Governance - -[GOVERNANCE_RULES] - +This constitution supersedes all other development practices and guidelines. It defines the non-negotiable principles for TenantPilot development. -**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] - +**Amendment Process**: +- Amendments require documentation in this file with version bump rationale +- Version follows **semantic versioning**: + - **MAJOR**: Backward-incompatible governance changes (principle removal/redefinition) + - **MINOR**: New principles added or materially expanded guidance + - **PATCH**: Clarifications, wording improvements, non-semantic refinements +- Constitution changes MUST propagate to affected templates and guidance files +- All templates in `.specify/templates/` MUST align with active principles + +**Compliance Enforcement**: +- All specs MUST include a "Constitution Check" section in plan.md +- All PRs MUST verify compliance with relevant principles +- Violations MUST be justified in plan.md Complexity Tracking section +- Runtime development guidance lives in `Agents.md`, which MUST align with this constitution + +**Ratification**: This constitution was established to formalize TenantPilot's architectural and operational principles based on v1 specification requirements and the Spec Kit workflow. + +**Version**: 1.0.0 | **Ratified**: 2025-12-10 | **Last Amended**: 2025-12-10 diff --git a/.specify/plan.md b/.specify/plan.md new file mode 100644 index 0000000..b641e8e --- /dev/null +++ b/.specify/plan.md @@ -0,0 +1,70 @@ +# Implementation Plan: TenantPilot v1 + +**Branch**: `tenantpilot-v1` | **Date**: 2025-12-10 | **Spec**: .specify/spec.md +**Input**: Feature specification from `.specify/spec.md` + +## Summary + +TenantPilot v1 delivers admin-facing Intune inventory across the scoped object types (config, compliance, scripts, apps, CA, endpoint security, enrollment/autopilot), immutable backups, version history with diffs, and defensive restore (per-type restore levels from the matrix: enabled vs preview-only) in Filament. Data is tenant-aware with JSONB snapshots, Graph access is isolated behind an abstraction, and operations are audited. Local development uses Sail with PostgreSQL; deployments go through Dokploy staging before production. + +## Technical Context + +**Language/Version**: PHP 8.2+, Laravel 12, Filament 4 +**Primary Dependencies**: Laravel framework, Filament admin, Pest, Laravel Sail, Vite/Tailwind 4 for assets +**Storage**: PostgreSQL (JSONB for policy snapshots, backups, versions) +**Testing**: Pest via `./vendor/bin/sail artisan test` (graph boundaries mocked) +**Target Platform**: Containerized web app on Dokploy (Staging → Production), Filament admin UI +**Project Type**: Web (Laravel monolith with Filament) +**Performance Goals**: Admin portal responsiveness (<1s typical page loads), Graph calls rate-limit aware; migrations avoid long locks +**Constraints**: Safety-first ops (preview/confirm, audit), reversible migrations validated on staging, tenant-scoped queries, no secrets in code, JSONB retention to avoid unbounded growth; per-type restore behavior follows `scope.restore_matrix` (e.g., CA/enrollment restrictions = preview-only) +**Scale/Scope**: Single-tenant deployment (tenant-aware schema), multi-type coverage driven by `scope.supported_types` + restore matrix + +## Constitution Check + +- Safety-First Operations: Restore/rollback flows require preview + explicit confirmation; conflict warnings surfaced; dry-run supported. ✅ +- Immutable Versioning: Policy versions/backups stored as immutable JSONB snapshots; no in-place edits; diffs available. ✅ +- Defensive Restore: Preview/dry-run, selective items, conflict detection, pre-execution summary, confirmation gate. ✅ +- Auditability: Backup creation, version capture, restore start/result (success/failure/partial) audited with tenant scoping. ✅ +- Tenant-Aware Architecture: Tenant entity present; all policy/version/backup/restore/audit records reference tenant context; queries enforce isolation. ✅ +- Graph Abstraction: All Graph calls through a dedicated adapter/service with standardized error mapping and logging (no direct Graph in UI/domain). ✅ +- Spec-Driven Development: Spec + plan present in `.specify/`; tasks to follow; constitution check complete before implementation. ✅ + +## Project Structure + +### Documentation (this feature) + +```text +.specify/ +├── spec.md # Feature specification (v1 scope) +├── plan.md # This plan +└── tasks.md # To be generated from plan/spec +``` + +### Source Code (repository root) + +```text +app/ +├── Models/ +├── Http/Controllers/ +├── Filament/ # Admin resources/pages/widgets +├── Services/Graph/ # Graph abstraction layer (planned) +├── Services/Intune/ # Domain orchestration (planned) +├── Actions/Jobs/Events/ # Async and domain events +database/ +├── migrations/ +├── seeders/ +resources/ +├── views/ # Blade/Vite-driven assets +routes/ +├── web.php +├── console.php +tests/ +├── Feature/ # Filament + flow coverage +└── Unit/ +``` + +**Structure Decision**: Single Laravel monolith with Filament admin. Tenant-aware data stored in PostgreSQL JSONB; Graph access isolated under `app/Services/Graph/`; domain services for backups/versions/restores live under `app/Services/Intune/`. + +## Complexity Tracking + +No constitution violations; complexity tracking not required. diff --git a/.specify/spec.md b/.specify/spec.md new file mode 100644 index 0000000..dd8f100 --- /dev/null +++ b/.specify/spec.md @@ -0,0 +1,393 @@ +# Feature Specification: TenantPilot v1 + +**Feature Branch**: `tenantpilot-v1` +**Created**: 2025-12-10 +**Status**: Draft +**Input**: TenantPilot v1 scope covering Intune configuration inventory (config, compliance, scripts, apps, conditional access, endpoint security, enrollment/autopilot, RBAC), backup, version history, and defensive restore for Intune administrators. + +## Scope + +```yaml +scope: + description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und – je nach Risikoklasse – wiederherstellen können." + supported_types: + - key: deviceConfiguration + name: "Device Configuration" + graph_resource: "deviceManagement/deviceConfigurations" + notes: "Inklusive Custom OMA-URI, Administrative Templates und Settings Catalog." + + - key: deviceCompliancePolicy + name: "Device Compliance" + graph_resource: "deviceManagement/deviceCompliancePolicies" + + - key: appProtectionPolicy + name: "App Protection (MAM)" + graph_resource: "deviceAppManagement/managedAppPolicies" + notes: "iOS und Android Managed App Protection." + + - key: conditionalAccessPolicy + name: "Conditional Access" + graph_resource: "identity/conditionalAccess/policies" + notes: "Kritisch für Sicherheit. Policy.Read.All/Policy.ReadWrite.All nötig; v1: Restore nur mit starker Preview." + + - key: deviceManagementScript + name: "PowerShell Scripts" + graph_resource: "deviceManagement/deviceManagementScripts" + notes: "scriptContent wird beim Backup base64-decoded gespeichert und beim Restore wieder encoded (vgl. FR-020)." + + - key: enrollmentRestriction + name: "Enrollment Restrictions" + graph_resource: "deviceManagement/deviceEnrollmentConfigurations" + + - key: windowsAutopilotDeploymentProfile + name: "Windows Autopilot Profiles" + graph_resource: "deviceManagement/windowsAutopilotDeploymentProfiles" + + - key: windowsEnrollmentStatusPage + name: "Enrollment Status Page (ESP)" + graph_resource: "deviceManagement/deviceEnrollmentConfigurations" + filter: "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'" + + - key: endpointSecurityIntent + name: "Endpoint Security Intents" + graph_resource: "deviceManagement/intents" + notes: "Account Protection, Disk Encryption etc.; Zuordnung über bekannte Templates." + + - key: mobileApp + name: "Applications (Metadata only)" + graph_resource: "deviceAppManagement/mobileApps" + notes: "Backup nur von Metadaten/Zuweisungen (kein Binary-Download in v1)." + + restore_matrix: + deviceConfiguration: + backup: full + restore: enabled + risk: medium + notes: "Standard-Case für Backup+Restore; starke Preview/Audit Pflicht." + + deviceCompliancePolicy: + backup: full + restore: enabled + risk: medium + notes: "Compliance-Änderungen können Zugriff beeinflussen, aber sind gut verständlich." + + appProtectionPolicy: + backup: full + restore: enabled + risk: medium-high + notes: "MAM-Änderungen wirken auf Datenzugriff in Apps; Preview und Diff wichtig." + + conditionalAccessPolicy: + backup: full + restore: preview-only + risk: high + notes: "Hohe Ausfallgefahr. v1: Backup, Versioning, Diff + ausführliche Preview; Restore nur manuell anhand Preview." + + deviceManagementScript: + backup: full + restore: enabled + risk: medium + notes: "Script-Inhalt und Einstellungen werden gesichert; Decode/Encode beachten." + + enrollmentRestriction: + backup: full + restore: preview-only + risk: high + notes: "Kann Enrollment blockieren; v1 eher nur Preview + manuelle Umsetzung." + + windowsAutopilotDeploymentProfile: + backup: full + restore: enabled + risk: medium-high + notes: "Provisioning-kritisch; Preview + Audit, aber automatisierbar." + + windowsEnrollmentStatusPage: + backup: full + restore: enabled + risk: medium + notes: "ESP beeinflusst OOBE UX; Änderungen klar sichtbar." + + endpointSecurityIntent: + backup: full + restore: enabled + risk: high + notes: "Security-relevante Einstellungen (z. B. Credential Guard); Preview + klare Konflikt-Warnungen nötig." + + mobileApp: + backup: metadata-only + restore: enabled + risk: low-medium + notes: "Nur Metadaten/Zuweisungen; kein Binary; Restore setzt Konfigurationen/Zuweisungen wieder." +``` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Policy inventory listing (Priority: P1) + +Admin can view supported Intune object types (as defined in the scope) with normalized metadata for selection. + +**Why this priority**: Inventory is the entry point for backup/version flows. Without it, no downstream workflows are usable. + +**Independent Test**: From Filament, navigate to Policies; verify supported types render with identifiers, type/category, platform metadata, and tenant scoping. + +**Acceptance Scenarios**: + +1. **Given** an authenticated admin, **When** they open the Policies list, **Then** they see supported object types with identifiers, type/category, platform, and last-updated metadata. +2. **Given** filtering by type/category, **When** the admin selects a type, **Then** only matching objects appear and the view remains tenant-scoped. + +--- +### User Story 1b - Policy detail shows readable settings (Priority: P1) + +Admin can open a policy detail page and see the **effective Intune settings** in a readable, normalized way (not raw JSON dumps). + +**Independent Test**: From Filament, open a policy detail view; verify a "Settings" section renders normalized key/value pairs (or tables for special cases) derived from the latest snapshot. + +**Acceptance Scenarios**: + +1. **Given** a policy with at least one captured snapshot, **When** the admin opens the policy detail view, **Then** they see a "Settings" section rendering the policy configuration in a readable format (grouped/labeled). +2. **Given** the snapshot contains nested structures or list-based settings (e.g., OMA-URI / Settings Catalog), **When** the admin views settings, **Then** values are flattened/grouped or rendered as tables, and irrelevant metadata keys are hidden. +--- +### User Story 2 - Backup creation and browsing (Priority: P1) + +Admin creates backup sets containing multiple objects (config, compliance, scripts, apps, CA, etc.) with immutable snapshots and can browse backup details in Filament. + +**Why this priority**: Backups provide safety and enable restore; immutability and audit are foundational. + +**Independent Test**: Initiate a backup set selecting multiple objects; confirm immutable JSONB snapshots persisted, audit log written, and Filament shows backup detail and items. + + +**Acceptance Scenarios**: + +1. **Given** selected objects from different categories, **When** the admin creates a backup set, **Then** backup items store immutable payload snapshots (full or metadata-only as per the restore matrix) with identifiers and types. +2. **Given** a completed backup set, **When** the admin opens its detail page, **Then** all items and metadata display along with the audit record of creation. + +3. **Given** mehrere Backup-Sets existieren, + **When** der Admin ein Backup-Set auswählen oder ansehen möchte, + **Then** sieht er für jedes Set: + - einen sprechenden Namen (nicht nur Timestamp), + - das Erstellungsdatum, + - die Anzahl der enthaltenen Items, + - und optional eine kurze Beschreibung, damit er das Set sinnvoll unterscheiden kann. + +--- + +### User Story 3 - Version history and diff (Priority: P1) + +Admin can capture versions for any supported object, view timelines, and compare any two versions with meaningful diffs. + +**Why this priority**: Version visibility and diffs enable rollback readiness and change comprehension. + +**Independent Test**: Create multiple versions for a given object; verify timeline ordering, version metadata, and diff output (human summary + JSON diff where feasible) between any two versions. + +**Acceptance Scenarios**: + +1. **Given** an admin triggers version capture for an object, **When** the version is saved, **Then** an immutable snapshot and metadata (actor, time, type, tenant) are recorded. +2. **Given** two versions of the same object, **When** the admin requests a comparison, **Then** the UI shows a human-readable summary and structured JSON diff where available. +3. **Given** a saved policy version, **When** the admin opens the version detail page, **Then** the snapshot is displayed as pretty-printed JSON and, where possible, as normalized settings (not as an unreadable serialized array/string). +--- + +### User Story 4 - Restore with preview and confirmation (Priority: P1) + +Admin can run a restore from a backup set with preview/dry-run, selective restore, clear warnings, and required confirmation before execution. + +**Why this priority**: Restore is high-risk; safety features are mandatory for production readiness. + +**Independent Test**: Start a restore from a backup set in preview; view change summary and warnings; select items; confirm execution; verify audit logs and outcomes recorded (success/failure/partial). + +**Acceptance Scenarios**: + +1. **Given** a backup set, **When** the admin initiates a restore in preview mode, **Then** the system shows a change summary with selectable items and conflict warnings. +2. **Given** selected items and explicit confirmation, **When** execution proceeds, **Then** applied changes are tenant-scoped and audit logs record start, result, and any failures. + +3. **Given** mehrere Backup-Sets existieren, + **When** der Admin einen Restore Run erstellt, + **Then** zeigt die Auswahl für das "Backup set" mindestens: + - den Backup-Namen, + - das Erstellungsdatum, + - die Anzahl der Items, + damit der Admin das richtige Backup-Set sicher auswählen kann. + +4. **Given** ein Restore Run wurde erstellt, + **When** der Admin die Detailseite des Restore Runs öffnet, + **Then** sieht er, welche Policies/Items in diesem Run enthalten sind + (z. B. Liste der Policies mit Name/Typ/Plattform). +--- + +### User Story 5 - Operational readiness and environments (Priority: P2) + +Local development uses Sail; deployments target Dokploy staging then production with clear validation steps. + +**Why this priority**: Ensures reproducible local setup and safe promotion to production. + +**Independent Test**: Run the app locally via Sail; validate migrations on staging before production; confirm required env vars and queues/workers are documented. + + + + +### User Story 6 - Berechtigungsübersicht & Health-Status (Priority: P1) + +Als Admin möchte ich für jeden Tenant sehen, welche Microsoft Graph-Berechtigungen +erforderlich sind, welche bereits erteilt wurden und welche fehlen, damit ich +sicherstellen kann, dass alle Funktionen von TenantPilot sicher und vollständig +arbeiten. + +**Why this priority**: Jede neue Funktion kann zusätzliche Berechtigungen benötigen. +Ohne transparente Übersicht und Abgleich besteht das Risiko, dass Features still +kaputt sind oder unsicher laufen. + +**Acceptance Scenarios**: + +1. **Given** ein Tenant ist in TenantPilot hinterlegt, + **When** der Admin die Tenant-Detailseite öffnet, + **Then** sieht er eine Liste aller *erforderlichen* Berechtigungen mit Status + (z. B. OK, fehlt). + +2. **Given** neue Funktionen wurden eingeführt, die zusätzliche Berechtigungen benötigen + und diese wurden in der zentralen Permissions-Liste hinzugefügt, + **When** der Admin die Tenant-Detailseite öffnet, + **Then** erscheinen die neuen Berechtigungen automatisch in der Übersicht und + fehlende Berechtigungen werden klar als fehlend markiert. + +3. **Given** der Admin klickt auf "Verify configuration", + **When** TenantPilot einen Graph-Twestcall und/oder das Permission-Setup prüft, + **Then** wird der Status der Berechtigungen aktualisiert (OK/fehlt/Fehler) und + es wird ein Audit-Eintrag erstellt. + +4. **Given** ein Tenant hat fehlende kritische Berechtigungen, + **When** andere Features (Policy-Sync, Backup, Restore) diesen Tenant verwenden, + **Then** kann TenantPilot dem Admin entsprechende Warnungen anzeigen oder die + Funktion mit einem klaren Fehler abbrechen. + + +**Acceptance Scenarios**: + +1. **Given** a fresh checkout, **When** Sail commands run (`./vendor/bin/sail up -d`, `./vendor/bin/sail artisan migrate`), **Then** the app boots with PostgreSQL and Filament admin available. +2. **Given** a pending release, **When** migrations and restore flows are validated on staging, **Then** production deployment proceeds with documented steps and environment parity. + +### Edge Cases + +- Graph permissions missing or expired, causing policy fetch/restore failures with clear error mapping and audit entries. +- Large policy payloads or many items in a backup set; ensure JSONB storage and pagination handle load without timeouts. +- Restore conflicts when target tenant already has newer versions; preview must surface warnings and allow skip. +- Partial restore failures; audits must capture per-item outcomes and surface retry guidance. +- Diff generation for incompatible or malformed payloads should fail gracefully with admin-facing messaging. +- Retention/size concerns for snapshots; document defaults and guard against unbounded growth. +- Snapshots stored as serialized strings or array-only dumps (keys lost) must be detected; UI should show a clear warning and fall back to raw display. +- Policies whose `@odata.type` does not match the expected platform/type mapping should be flagged to prevent wrong restore previews (e.g., stored as Windows but snapshot indicates Android). +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST list all Intune objects defined in the `scope.supported_types` section with normalized metadata and tenant scoping for selection. +- **FR-002**: System MUST allow admins to create backup sets containing multiple objects (configuration, compliance, scripts, apps, conditional access, etc.) with immutable JSONB payload snapshots. +- **FR-003**: Backup creation MUST log audit events including actor, timestamp, tenant, items, and outcome. +- **FR-004**: System MUST capture policy versions on demand and present per-policy timelines. +- **FR-005**: Users MUST be able to diff any two versions with a human-readable summary and structured JSON diff where feasible. +- **FR-006**: Restore MUST support preview/dry-run, selective item restore, and explicit confirmation before applying changes, within the per-type restore level defined in `scope.restore_matrix`. +- **FR-007**: Restore execution MUST produce audit logs covering success, failure, and partial outcomes. +- **FR-008**: Graph integration MUST route through a dedicated abstraction layer with standardized error mapping, safe retries, and high-level logging without secrets. +- **FR-009**: All policy, version, backup, and restore data MUST be tenant-aware; queries enforce tenant isolation. +- **FR-010**: Application MUST run locally via Laravel Sail with PostgreSQL and provide Filament admin flows. +- **FR-011**: Deployments MUST target Dokploy staging before production with documented migration and worker implications. +- **FR-012**: Tests MUST cover backup composition rules, version immutability, audit events, and Filament backup/restore flows (with Graph boundaries mocked). +- **FR-013**: Raw policy snapshots and backup payloads MUST be stored as JSONB with indexes justified by query needs (e.g., FK and time-based; GIN when filters require). +- **FR-014**: UI MUST provide clear warnings for potential restore conflicts and require confirmation for destructive operations; for types with `restore: preview-only` in `scope.restore_matrix` no direct apply action MAY be offered. +- **FR-015**: Admins MUST be able to safely delete (archive) backup sets that are no + longer needed. Deletion is implemented as soft-delete with audit logging, and + backup sets referenced by completed restore runs cannot be removed. + +- **FR-016**: Admins MUST be able to delete individual policy versions for housekeeping. + Deletion is implemented as soft-delete with audit logging. + +- **FR-017**: Admins MUST be able to deactivate (soft-delete) a tenant. +Deactivated + tenants: + - do not appear in default lists, + - cannot be used for new sync/backup/restore operations, + - keep their historical data and audit logs for traceability. +- **FR-018**: Admins MAY soft-delete restore runs to keep the UI clean; underlying + backup and policy data remains untouched. + +- **FR-019**: The system MUST normalize different payload structures for display via a `PolicyNormalizer` (or equivalent): OMA-URI/custom policies as path/value tables, Settings Catalog policies as flattened structures, and standard objects as key-value views, aligned with `scope.supported_types`. +- **FR-019a**: Policy detail views MUST display a "Settings" section derived from the latest available snapshot (using the normalizer output when available). +- **FR-019b**: Policy version detail views MUST render snapshots as pretty-printed JSON (monospace, copyable) and SHOULD also render normalized settings via the same normalizer. +- **FR-020**: For PowerShell script objects (`deviceManagementScript` in `scope.supported_types`), the `scriptContent` MUST be base64-decoded when stored in backups/versions for readability/diffing and encoded again when sent back to Graph during restore. +- **FR-021**: Restore behavior MUST follow the per-type configuration in `scope.restore_matrix`: `backup` determines full vs metadata-only snapshots; `restore` determines whether automated restore is enabled or preview-only; `risk` informs warning/confirmation UX. +- **FR-022**: For high-risk types with `restore: preview-only` in `scope.restore_matrix` (e.g., `conditionalAccessPolicy`, `enrollmentRestriction`), TenantPilot MUST provide full backups, version history, and diffs plus detailed restore previews, but MUST NOT expose direct Graph apply actions; restore is manual, guided by the preview. + +### Key Entities *(include if feature involves data)* + +- **tenants**: Represents the deployment tenant context; referenced by all scoped data. +- **policies**: Normalized metadata for supported Intune policies. +- **policy_versions**: Immutable snapshots with metadata (actor, timestamp, tenant, policy type). +- **backup_sets**: Group of backup items with creator, timestamp, and tenant context. +- **backup_items**: Individual policy snapshots within a backup set (immutable JSONB payload + identifiers). +- **restore_runs**: Execution records for restores, including preview/actual flags and outcomes. +- **audit_logs**: Audit trail entries for backups, restores, version captures, and significant Graph actions. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Admin can create a backup set selecting multiple policies and view immutable backup items with audit logs in Filament. +- **SC-002**: Policy version history timeline is available per policy and supports comparing any two versions with summary and JSON diff outputs. +- **SC-003**: Restore preview shows change summaries and conflict warnings; execution requires explicit confirmation and produces audit logs for all outcomes. +- **SC-004**: Core flows run locally via Sail; staging validation of migrations and restore paths completes before production deployments. +- **SC-005**: Automated tests covering backup composition, version immutability, audit logging, and Filament backup/restore flows pass via `./vendor/bin/sail artisan test`. + + +### Technical Story – Enforce Single Current Tenant ("Highlander Principle") + +**Context** + +Aktuell können mehrere Tenants `status = active` sein. Graph-Operationen (Policy Sync, +Backup, Restore) wählen den Kontext über Heuristiken (`findOrCreateDefault`, +`local-tenant`), was zu falschen Tenants und Fehlern führt. + +**Goal** + +Es soll **immer genau einen klar definierten "current" Tenant** geben, über den +alle Graph-Operationen laufen. Die Auswahl dieses Tenants ist explizit und +transparent (UI + Env), nicht implizit. + +**Requirements** + +- Es gibt ein Flag `is_current` in `tenants`, das den aktuell verwendeten Kontext + markiert. +- Die Datenbank erzwingt per partiellem Unique Index, dass höchstens ein + nicht-gelöschter Tenant `is_current = true` haben kann. +- `Tenant::current()` liefert: + - falls `INTUNE_TENANT_ID` gesetzt ist, **genau diesen** Tenant (Fehler, wenn + er nicht existiert oder deaktiviert ist), + - sonst den Tenant mit `is_current = true` und `status = active`. + - falls keiner gefunden wird, eine klare Exception (“No current tenant selected”); + es werden keine Dummy-Tenants erzeugt. +- In der Tenant-Verwaltung gibt es eine Action "Make current", die: + - in einer Transaktion alle anderen Tenants auf `is_current = false` setzt + und den gewählten Tenant auf `is_current = true`, + - nur für aktive Tenants verfügbar ist. +- Der frühere Placeholder `local-tenant` darf nicht mehr als Graph-Kontext genutzt + werden; sobald ein echter Tenant existiert, wird er archiviert und ist nie + `is_current`. +- Alle Graph-basierten Funktionen (Policy Sync, Backup, Restore) verwenden + konsistent `Tenant::current()` oder einen explizit übergebenen Tenant. + + Tenant-level actions such as "Admin consent" and "Verify configuration" +MUST be exposed on the tenant detail view (and/or row actions), not as a +global button without explicit tenant context. + + +### UX Guideline – Table Actions / Dropdowns + +- Tabellen in Filament mit mehr als zwei Zeilen-Aktionen (z.B. View, Edit, + Admin consent, Verify, Deactivate, Force delete) MÜSSEN ihre Aktionen in + einem kompakten Dropdown / ActionGroup bündeln, statt alle Buttons nebeneinander + anzuzeigen. +- Ausnahmen: besonders häufige, nicht-destruktive Aktionen (z.B. "View") + dürfen weiterhin als einzelner Button sichtbar bleiben; alle weiteren + Aktionen (z.B. Admin-Aktionen, Housekeeping) sollen im Dropdown liegen. +- Ziel: die Tabellen bleiben übersichtlich, Spaltenbreite wird begrenzt, + und Admins bekommen eine konsistente "⋯"-Interaktion für erweiterte Aktionen. + + +Previous draft archived under spechistory/spec.md \ No newline at end of file diff --git a/.specify/tasks.md b/.specify/tasks.md new file mode 100644 index 0000000..5118988 --- /dev/null +++ b/.specify/tasks.md @@ -0,0 +1,318 @@ +--- + +description: "Task list for TenantPilot v1 implementation" +--- + +# Tasks: TenantPilot v1 + +**Input**: Design documents from `.specify/spec.md` and `.specify/plan.md` +**Prerequisites**: plan.md (complete), spec.md (complete) + +## Phase 1: Setup (Shared Infrastructure) + +- [x] T001 [P] [Shared] Confirm Sail/Env ready; ensure `.env` has PostgreSQL settings for Sail and Filament admin user seeded (if missing) in `database/seeders/`. +- [x] T002 [P] [Shared] Add baseline docs for local dev and staging promotion notes in `README.md` (Sail commands, staging-before-prod reminder). + +## Phase 2: Foundational (Blocking Prerequisites) + +- [x] T003 [Shared] Add tenant-aware migrations for `tenants`, `policies`, `policy_versions`, `backup_sets`, `backup_items`, `restore_runs`, `audit_logs` with JSONB payloads and FK/time indexes in `database/migrations/`. +- [x] T004 [Shared] Create models with relationships and guarded attributes for the above entities in `app/Models/`. +- [x] T005 [Shared] Implement Graph abstraction contracts (`GraphClientInterface`, error mapping, logging hooks) in `app/Services/Graph/` with a mockable adapter. +- [x] T006 [Shared] Add audit logging service/helper to capture actor, tenant, operation, resources, outcome in `app/Services/Intune/AuditLogger.php`. +- [x] T007 [Shared] Seed supported policy types/metadata for initial scope in `database/seeders/PoliciesSeeder.php` and ensure tenant scoping. + +## Phase 3: User Story 1 - Policy inventory listing (Priority: P1) + +### Tests for User Story 1 + +- [x] T008 [P] [US1] Feature test for Filament policy listing and filtering (tenant-scoped) in `tests/Feature/Filament/PolicyListingTest.php` using mocked Graph sync. + +### Implementation for User Story 1 + +- [x] T009 [US1] Implement policy sync/import orchestrator using Graph abstraction in `app/Services/Intune/PolicySyncService.php` (no direct Graph in UI). +- [x] T010 [US1] Create Filament resource/table for policies with filters and metadata columns in `app/Filament/Resources/PolicyResource.php`. +- [x] T011 [US1] Add command/job to sync policies (queues-ready) in `app/Console/Commands/SyncPolicies.php` and queue job under `app/Jobs/`. + +## Phase 4: User Story 2 - Backup creation and browsing (Priority: P1) + +### Tests for User Story 2 + +- [x] T012 [P] [US2] Feature test for creating backup sets with multiple policies and verifying immutable JSONB snapshots + audit log in `tests/Feature/Filament/BackupCreationTest.php`. + +### Implementation for User Story 2 + +- [x] T013 [US2] Implement backup domain service to assemble snapshots from policies with Graph payload retrieval in `app/Services/Intune/BackupService.php`. +- [x] T014 [US2] Add Filament resource/pages for backup sets and items (list/detail) in `app/Filament/Resources/BackupSetResource.php`. +- [x] T131 [UX] [US2] Refactor BackupSet policy selection to RelationManager: + - Remove the multi-select policy picker from the BackupSet **Create** form (keep Create minimal: name/description). + - After create, redirect to BackupSet **Edit/View** where items can be managed. + - Add `BackupItemsRelationManager` to `BackupSetResource` showing a table with columns: Policy Name, Type (badge), Restore (badge), Risk (badge). + - Add header action “Policies hinzufügen” (searchable, multiple) that adds items/attaches policies **tenant-scoped** and prevents duplicates per BackupSet. + - Provide a remove action (detach/soft-delete as per domain rules). + +- [x] T132 [P] [US2] Update/extend `tests/Feature/Filament/BackupCreationTest.php` to cover the new UX flow: + - Create BackupSet without policies. + - Add multiple policies via RelationManager action. + - Verify immutable JSONB snapshots + audit log behavior remains correct. +- [x] T015 [US2] Wire audit logging for backup creation events in `app/Services/Intune/BackupService.php` using `AuditLogger`. + +## Phase 5: User Story 3 - Version history and diff (Priority: P1) + +### Tests for User Story 3 + +- [x] T016 [P] [US3] Feature test for version capture and timeline display in `tests/Feature/Filament/PolicyVersionTest.php`. +- [x] T017 [P] [US3] Unit test for diff generation (human summary + JSON diff) in `tests/Unit/VersionDiffTest.php`. + +### Implementation for User Story 3 + +- [x] T018 [US3] Implement version capture service with immutable JSONB writes in `app/Services/Intune/VersionService.php`. +- [x] T019 [US3] Create diff helper (summary + structured JSON) in `app/Services/Intune/VersionDiff.php` and surface in Filament version compare view in `app/Filament/Resources/PolicyVersionResource.php`. +- [x] T020 [US3] Hook version capture into relevant flows (manual trigger + backup/restore hooks) ensuring audit logging. + +## Phase 6: User Story 4 - Restore with preview and confirmation (Priority: P1) + +### Tests for User Story 4 + +- [x] T021 [P] [US4] Feature test for restore preview (change summary, conflicts, selective items) in `tests/Feature/Filament/RestorePreviewTest.php`. +- [x] T022 [P] [US4] Feature test for confirmed restore execution capturing audit logs and per-item outcomes in `tests/Feature/Filament/RestoreExecutionTest.php`. + +### Implementation for User Story 4 + +- [x] T023 [US4] Implement restore service with preview/dry-run and selective item application in `app/Services/Intune/RestoreService.php`, integrating Graph adapter and conflict detection. +- [x] T024 [US4] Add Filament restore UI (wizard or pages) showing preview, warnings, and confirmation gate in `app/Filament/Resources/RestoreRunResource.php`. +- [x] T025 [US4] Record restore run lifecycle (start, per-item result, completion) and audit events in `restore_runs` and `audit_logs`. + +## Phase 7: User Story 5 - Operational readiness and environments (Priority: P2) + +### Implementation for User Story 5 + +- [x] T026 [US5] Document Dokploy staging→production promotion steps, required env vars, queue/worker expectations, and migration safety notes in `README.md` or `docs/deploy.md`. +- [x] T027 [US5] Add quick Sail commands and test invocation notes to `README.md` (e.g., `./vendor/bin/sail artisan test`) and ensure sample env entries for Graph credentials. +## Phase 8: User Story 6 - Tenant hinzufügen & Entra ID App-Setup (Priority: P1) + +- [x] T030 [US6] Migration für `tenants` ergänzen/prüfen: + - Felder: `name`, `tenant_id` (GUID), `domain`, `app_client_id`, `app_status`, `app_notes`, + `created_at`, `updated_at`. + - Optional: Felder für Secret/Certificate-Config (verschlüsselt), falls benötigt. + +- [x] T031 [US6] Eloquent Model `Tenant`: + - Beziehungen zu `policies`, `backup_sets`, `restore_runs`, `policy_versions`, `audit_logs` + über `tenant_id`. + - Tenant-aware Scopes, falls vorhanden (z. B. `forTenant()`). + +- [x] T032 [US6] Filament-Resource `TenantResource`: + - Listenansicht: Name, Tenant ID, Domain, App-Status, erstellt/am. + - Create/Edit-Form: Name, Tenant ID, Domain, App-Client-ID, optionale Notizen. + - Detailseite mit Actions: + - „Open in Entra“ (Link zur App/Tenant im Entra-Portal), + - optional: „Copy Admin Consent URL“. + +- [x] T033 [US6] `TenantConfigService` (oder Erweiterung des Graph-Clients): + - Methode `testConnectivity(Tenant $tenant)`: führt einen einfachen Graph-Call aus + (z. B. `/organization` oder ähnliches) mit den App-Daten des Tenants. + - Rückgabe: DTO/Array mit `success`, `error_message` (falls vorhanden). + +- [x] T034 [US6] Action „Verify configuration“ in `TenantResource`: + - Ruft `testConnectivity()` auf, + - setzt `app_status` auf z. B. `ok`, `error` oder `consent_required`, + - zeigt eine Filament-Notification mit dem Ergebnis, + - schreibt einen Audit-Log-Eintrag (`tenant.config.verified`). + +- [x] T035 [US6] Tenant-Kontext in bestehende Services integrieren: + - `PolicySyncService`, `BackupService`, `RestoreService` so anpassen, + dass sie einen `Tenant` oder `tenant_id` übergeben bekommen + und den Graph-Client mit diesem Kontext verwenden. + - Sicherstellen, dass alle policy/backup/restore/audit-Datensätze `tenant_id` setzen. + +- [x] T036 [US6] Feature-Test `TenantSetupTest`: + - Erstellen eines Tenants via Filament (Create-Form). + - Aufruf der Action „Verify configuration“ mit gemocktem Graph-Client: + - einmal mit erfolgreichem Call → `app_status = ok`, + - einmal mit Fehler → `app_status = error` + passende Notification. + - Prüfen, dass Audit-Logs geschrieben werden. + +- [x] T037 [US6] Admin-Consent Callback Route + - Route/Controller, der als `redirect_uri` der Entra-ID-App dient. + - Liest `tenant` / `error` / `admin_consent` aus der Query. + - Ordnet das dem richtigen `Tenant` zu (z. B. via `state`). + - Aktualisiert `app_status` (z. B. `ok`, `error`, `consent_denied`). + - Zeigt eine Bestätigungs-/Fehlerseite für den Admin. +--- + +## Phase 9: User Story 7 - Berechtigungsübersicht & Health-Status (Priority: P1) + +- [x] T040 [US7] Zentrale Permissions-Liste anlegen: + - `config/intune_permissions.php` mit allen aktuell benötigten Graph-Berechtigungen: + - technischer Name (z. B. `DeviceManagementConfiguration.ReadWrite.All`), + - Typ: `application` / `delegated`, + - kurze Beschreibung, + - Feature-Tags (z. B. `["policy-sync", "backup"]`). + - Optional: `docs/permissions.md` mit einer Tabelle Feature ↔ Permission als + menschlich lesbare Referenz. + +- [x] T041 [US7] Datenmodell für Tenant-Berechtigungen: + - Variante A (einfach): JSONB-Feld `granted_permissions` in `tenants` (Liste von Permission-Keys). + - Variante B (feiner): Tabelle `tenant_permissions` mit + `(tenant_id, permission_key, status, last_checked_at)`. + - `status` mindestens: `ok`, `missing`, `error`. + +- [x] T042 [US7] Service `TenantPermissionService`: + - `getRequiredPermissions(): array` – liest aus `config/intune_permissions.php`. + - `getGrantedPermissions(Tenant $tenant): array` – liest aus Graph oder aus + `tenant_permissions`/`granted_permissions`. + - `compare(Tenant $tenant): TenantPermissionStatusDTO` – liefert pro Permission + den Status (ok/missing/error) + Gesamthealth. + +- [x] T043 [US7] Integration in Tenant-Detail-UI: + - Auf der `TenantResource`-Detailseite ein Panel/Section „Permissions“: + - Liste aller **required permissions**, + - pro Zeile: Name, Typ, Feature-Tags, Status (Icon + Label: OK/fehlt/Fehler). + - Optional: Link zu Doku oder Entra-Darstellung (z. B. „How to grant these permissions“). + +- [x] T044 [US7] Action „Verify configuration“ erweitern: + - Zusätzlich zu `testConnectivity()` auch `TenantPermissionService::compare()` aufrufen. + - Ergebnisse in `tenant_permissions`/`granted_permissions` speichern. + - `app_status` und Permission-Health aktualisieren. + - Audit-Log-Eintrag `tenant.permissions.checked` schreiben. + +- [x] T045 [US7] Tests für Permissions: + - Unit-Tests für `TenantPermissionService::compare()`: + - Szenarien: alle ok, Permission fehlt, Graph-Error. + - Feature-Test für Tenant-Detailseite: + - required permissions werden angezeigt, + - fehlende werden als fehlend markiert, + - „Verify configuration“ aktualisiert den Status wie erwartet. + +## Phase 9b: Scope-Ausrichtung auf neue Objekttypen + +- [x] T028 [Scope] Konfiguration `config/tenantpilot.php` auf die in `scope.supported_types` definierten Objekttypen erweitern (type/key, endpoint, label/category, optional risk/restore-Hinweis). Sicherstellen, dass diese Liste die einzige Quelle für Policy-Sync/Backup/Restore ist. +- [x] T029 [Scope] Filament-UI an neue Typen anpassen: Tabellenfilter/Grouping nach Kategorie (z. B. Config/Compliance/Scripts/Apps/CA), Backup/Restore-Formulare mit Hinweisen zu Restore-Level aus `scope.restore_matrix` (z. B. CA/enrollment restrictions = preview-only). + + +## Phase 10: Housekeeping – Delete-Funktionen für Backups & Versions + +- [x] T060 [HK] BackupSets soft deletable machen: + - `backup_sets` (und ggf. `backup_items`) Migration/Model mit `SoftDeletes` (deleted_at). + - Sicherstellen, dass RestoreRuns keine gelöschten BackupSets verwenden; Delete nur erlauben, + wenn keine zugehörigen RestoreRuns existieren. + +- [x] T061 [HK] Filament-Delete-Action für BackupSets: + - In `BackupSetResource` Delete-Action in List- und/oder Detail-View hinzufügen. + - Mit Confirmation-Dialog (“This will archive this backup set and hide it from the UI.”). + - Delete disabled/hidden, wenn `restore_runs` für das Set existieren. + - Nach Delete Audit-Log (`backup.deleted`) schreiben. + +- [x] T062 [HK] PolicyVersions soft deletable machen: + - `policy_versions` Migration/Model um `SoftDeletes` erweitern. + - Alle Queries und Filament-Resources so lassen, dass standardmäßig nur non-deleted Versions + angezeigt werden. + +- [x] T063 [HK] Filament-Delete-Action für PolicyVersions: + - In `PolicyVersionResource` Delete-Action hinzufügen (List/Detail). + - Confirmation + Audit-Log (`policy_version.deleted`). + +- [x] T064 [HK] Tests für Housekeeping: + - Feature-Test: Löschen eines BackupSets ohne RestoreRun → `deleted_at` gesetzt, UI-Eintrag weg, + Audit-Log vorhanden. + - Feature-Test: BackupSet mit RestoreRun → Delete-Action nicht verfügbar. + - Feature-Test: Löschen einer PolicyVersion → `deleted_at` gesetzt, nicht mehr in List sichtbar. + +## Phase 11: Housekeeping – Tenant löschen/deaktivieren + +- [x] T070 [HK] Tenants soft deletable machen: + - `tenants` Model um `SoftDeletes` erweitern, Migration ggf. `deleted_at` hinzufügen. + - Optional: Feld `status` (enum/string: `active`, `archived`) einführen; beim Delete auf `archived` setzen. + - Alle Standard-Queries für Tenants nur `active` / nicht gelöscht anzeigen. + +- [x] T071 [HK] Tenant-Delete-Action (Deaktivieren) in `TenantResource`: + - Delete-/Archive-Action in der Tenant-Liste und/oder Detailseite hinzufügen. + - Deutlich machen: “Deaktiviert diesen Tenant. Historische Daten bleiben vorhanden, neue Aktionen + sind nicht mehr möglich.” + - Bei Ausführung: + - `deleted_at` setzen (und ggf. `status = archived`), + - Audit-Log `tenant.deleted` oder `tenant.archived` schreiben. + +- [x] T072 [HK] Verhalten für deaktivierte Tenants: + - In `PolicySyncService`, `BackupService`, `RestoreService` prüfen, dass nur aktive Tenants + verwendet werden; bei deaktiviertem Tenant frühzeitig mit verständlicher Fehlermeldung abbrechen. + - In Filament-Navigation Tenants, Policies, Backups, Restores eines deaktivierten Tenants nicht + mehr in Standard-Listen anzeigen (es sei denn, es gibt explizite “Show archived”-Filter). + +- [x] T073 [HK] (Optional) RestoreRuns soft deletable machen: + - `restore_runs` Model/Migration mit `SoftDeletes`. + - Delete-Action in `RestoreRunResource` hinzufügen (nur UI-Aufräumung, keine Folgen für Backups). + - Audit-Log `restore_run.deleted` schreiben. + +- [x] T074 [HK] Tests für Tenant-Delete: + - Feature-Test: Tenant löschen/deaktivieren → Tenant taucht nicht mehr in Standardlisten auf, + `deleted_at` (und `status`) ist gesetzt, Audit-Event existiert. + - Feature-Test: Versuch, mit deaktiviertem Tenant einen Policy-Sync/Backup/Restore zu starten, + führt zu einem klaren Fehler (und kein Graph-Call wird ausgeführt). + +## Phase 12: Housekeeping – Hard Deletes (Force Delete) + +- [x] T075 [HK] Force-Delete-Actions ergänzen: + - Filament-Listen für Tenants, BackupSets, PolicyVersions, RestoreRuns erhalten „Force delete“ + Aktionen (sichtbar nur im Trashed-Filter), mit klarer Confirmation. + - BackupSets: Force delete nur, wenn keine RestoreRuns existieren; löscht Items mit. + - Tenants: Force delete nur, wenn archiviert; blockiert für aktive Tenants. + - Alle Force-Deletes schreiben Audit-Log-Einträge vor der endgültigen Löschung. + - Tests für Force-Delete-Flows (erfolgreich/blockiert) ergänzen. +## Phase 12: Single current tenant ("Highlander") + +- [x] T120 [TENANT] Migration `add_is_current_to_tenants`: + - Spalte `is_current` (boolean, default false, not null) zu `tenants` hinzufügen. + - Partielle Unique-Index anlegen, z. B.: + - `UNIQUE INDEX tenants_current_unique ON tenants (is_current) + WHERE is_current = true AND deleted_at IS NULL`. + +- [x] T121 [TENANT] Tenant-Model anpassen: + - Methode `makeCurrent()` implementieren: + - Transaktion: alle anderen Tenants `is_current = false`, dieser Tenant `is_current = true`. + - Methode `static current()` implementieren: + - Wenn `INTUNE_TENANT_ID` gesetzt ist → Tenant mit dieser GUID laden, + sonst Exception, wenn nicht gefunden / deaktiviert. + - Wenn nicht gesetzt → Tenant mit `is_current = true` und `status = active` + (und `deleted_at` null) zurückgeben. + - Wenn keiner → Exception “No current tenant selected”. + - `findOrCreateDefault()` deprecaten/entfernen; keine Dummy-Tenants mehr erzeugen. + +- [x] T122 [TENANT] Data-Migration / Cleanup: + - Falls mindestens ein Tenant mit `app_status = ok` existiert: + - einen als `is_current = true` markieren (z. B. den ersten). + - `local-tenant` auf `status = archived`, `is_current = false` setzen. + - Sicherstellen, dass `local-tenant` nie wieder als aktueller Kontext verwendet wird. + +- [x] T123 [TENANT] Filament `TenantResource` UI: + - Spalte/Badge für `is_current` in der Liste hinzufügen. + - Table-Action "Make current" ergänzen: + - nur sichtbar für aktive Tenants, die nicht `is_current` sind. + - ruft `makeCurrent()` auf und zeigt Notification. + - Alte Logik entfernen, die `local-tenant` automatisch als Default nutzt. + +- [x] T124 [TENANT] Consumers refactoren: + - Alle Vorkommen von `findOrCreateDefault()` suchen und durch `Tenant::current()` + (oder expliziten Tenant) ersetzen: + - Policy-Sync (Command + Filament-Action), + - BackupSet-Erstellung, + - RestoreRun-Erstellung, + - ggf. weitere Services. + +- [x] T125 [TENANT] Tests: + - Unit-Tests für `Tenant::current()`: + - INTUNE_TENANT_ID gesetzt → nimmt diesen Tenant, Fehler wenn nicht vorhanden. + - INTUNE_TENANT_ID nicht gesetzt → nimmt den mit `is_current = true`. + - kein current Tenant → Exception. + - Feature-Test für "Make current" in `TenantResource`: + - Nach der Action ist genau ein Tenant `is_current = true`, alle anderen `false`. + - Optional: Test, dass `local-tenant` nach Cleanup nicht mehr als Kontext gewählt wird. + + - [x] T130 [UX] Tabellen-Aktionen in Dropdown bündeln (ActionGroup) + - In `TenantResource` (Tenants-Liste) die Zeilen-Aktionen refaktorieren: + - `View` (optional) direkt anzeigen. + - Alle weiteren Aktionen (`Edit`, `Admin consent`, `Verify configuration`, + `Deactivate`, `Force delete`) in eine `Tables\Actions\ActionGroup` mit + "⋯"-Icon verschieben. + - Prüfen, ob in anderen Ressourcen mit vielen Row-Actions (z.B. Backups, + RestoreRuns) ebenfalls eine `ActionGroup` sinnvoll ist und diese konsistent + einsetzen. diff --git a/Agents.md b/Agents.md new file mode 100644 index 0000000..503d98a --- /dev/null +++ b/Agents.md @@ -0,0 +1,671 @@ +# TenantPilot - Agent Guidelines + +## Context +TenantPilot is an Intune Management application built with **Laravel** and **Filament**. +It re-implements and extends key features inspired by the IntuneManagement project, +with a focus on admin productivity, safe change management, and auditability. + +This repo uses GitHub Spec Kit. +Primary spec artifacts live in `.specify/`. + +**Sail-first for local development. Dokploy-first for staging/production.** + +## Product Goals +- Provide **Intune policy version control** (diff, history, rollback). +- Enable reliable **backup and restore** of Intune configurations. +- Extend Intune with **admin-focused features** that improve visibility, safety, and velocity. +- Prioritize **auditability**, **least privilege**, and predictable operations. + +## Scope Reference +When designing or implementing features, align with: +- Policy inventory & metadata normalization +- Change tracking and version snapshots +- Safe restore flows (dry-run, validation, partial restore) +- Reporting, dashboards, and operational insights +- Tenant-scoped RBAC and audit logs + +## Workflow (Spec Kit) +1. Read `.specify/constitution.md` +2. For new work: create/update `.specify/spec.md` +3. Produce `.specify/plan.md` +4. Break into `.specify/tasks.md` +5. Implement changes in small PRs + +If requirements change during implementation, update spec/plan before continuing. + +## Architecture Assumptions +- Backend: Laravel (latest stable) +- Admin UI: Filament +- Auth: Microsoft identity integration (Entra ID/Azure AD) when applicable +- External API: Microsoft Graph for Intune + +Do not assume additional services unless stated in spec. + +--- + +## DevOps & Environments + +### Local Development +- Local dev & testing use **Laravel Sail** (Docker). +- Prefer Sail commands when referencing setup or running tests. +- PostgreSQL is used locally via Sail. +- **Drizzle** is used locally for PostgreSQL tooling (e.g., schema inspection, dev workflows) + **if configured in the repo**. + +### Repository +- Repository is hosted on **Gitea**. +- Do not assume GitHub-specific features (Actions, GH-specific PR automation) + unless explicitly added. +- CI suggestions should be compatible with Gitea pipelines or external CI runners. + +### Deployment +- Deployed via **Dokploy** on a **VPS**. +- Two environments: + - **Staging** + - **Production** +- Assume container-based deployments. +- Changes that affect runtime must consider: + - environment variables + - database migrations + - queue/cron workers + - storage persistence/volumes + - reverse proxy/SSL likely handled by Dokploy + +### Release & Promotion Rules +- Staging is the mandatory validation gate for Production. +- Prefer: + - feature flags for risky admin operations + - staged rollout for backup/restore/versioning changes +- Schema changes must be validated on Staging before Production. + +### Release Safety +- For schema changes: + - provide safe, incremental migrations + - avoid long locks + - document rollback/forward steps +- For Intune-critical flows: + - prefer dry-run/preview + - require explicit confirmation + - ensure audit logs + +--- + +## Data Layer +- Database: **PostgreSQL** +- Prefer **JSONB** to store raw Graph policy snapshots and backup payloads. +- Add appropriate indexes (e.g., **GIN** on JSONB where search/filter is expected). +- Migrations must be reversible where possible. + +## Versioning Storage Strategy +- Store **immutable** policy snapshots. +- Track metadata separately (tenant, policy type, platform, created_by, created_at). +- Prefer **full snapshots first** for correctness and simplicity. +- Consider retention policies to prevent unbounded growth. + +--- + +## Engineering Rules +- PHP: follow PSR-12 conventions. +- Prefer Laravel best practices (Service classes, Jobs, Events, Policies). +- Keep Microsoft Graph integration isolated behind a dedicated abstraction layer. +- Use dependency injection and clear interfaces for Graph clients. +- No breaking changes to data structures or API contracts without updating: + - `.specify/spec.md` + - migration notes + - upgrade steps +- If a TypeScript/JS tooling package exists, use strict typing rules there too. + +## Intune Data & Safety Rules +- Treat Intune resources as **critical configuration**. +- Every destructive action must support: + - explicit confirmation UI + - audit log entry + - optional dry-run/preview mode if feasible +- Restore must be defensive: + - validate inputs + - detect conflicts + - allow selective restore + - show a clear pre-execution summary + +## Version Control Semantics +- A "version" should be reproducible and queryable: + - what changed + - when + - by whom + - source tenant/environment +- Provide diff outputs where possible: + - human-readable summary + - structured diff (JSON) + +## Observability & Audit +- Log Graph calls at a high-level (no secrets). +- Maintain an audit trail for: + - backups created + - restores executed/attempted + - policy changes detected/imported +- Ensure logs are tenant-scoped and RBAC-respecting. + +## Security +- Enforce least privilege. +- Never store secrets in config or code. +- Use Laravel encrypted storage or secure secret management where applicable. +- Validate all tenant identifiers and Graph scopes. + +--- + +## Commands + +### Sail (preferred locally) +- `./vendor/bin/sail up -d` +- `./vendor/bin/sail down` +- `./vendor/bin/sail composer install` +- `./vendor/bin/sail artisan migrate` +- `./vendor/bin/sail artisan test` +- `./vendor/bin/sail artisan` (general) + +### Drizzle (local DB tooling, if configured) +- Use only for local/dev workflows. +- Prefer running via package scripts, e.g.: + - `pnpm drizzle:generate` + - `pnpm drizzle:migrate` + - `pnpm drizzle:studio` + +(Agents should confirm the exact script names in `package.json` before suggesting them.) + +### Non-Docker fallback (only if needed) +- `composer install` +- `php artisan serve` +- `php artisan migrate` +- `php artisan test` + +### Frontend/assets/tooling (if present) +- `pnpm install` +- `pnpm dev` +- `pnpm test` +- `pnpm lint` + +--- + +## Where to look first +- `.specify/` +- `AGENTS.md` +- `README.md` +- `app/` +- `database/` +- `routes/` +- `resources/` +- `config/` + +--- + +## Definition of Done +- Spec + Plan + Tasks aligned with implementation. +- Tests added/updated. +- UI includes clear admin-safe affordances for backup/restore/versioning. +- Audit logging implemented for sensitive flows. +- Documentation updated (README or in-app help). +- Deployment impact assessed for: + - Staging + - Production + - migrations, env vars, queues + +--- + +## AI Usage Note +All AI agents must read: +- `AGENTS.md` +- `.specify/*` + +before proposing or implementing changes. + +## Reference Materials +- PowerShell scripts from IntuneManagement are stored under `/references/IntuneManagement-master` + for implementation guidance only. +- They must not be treated as production runtime dependencies. + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.15 +- filament/filament (FILAMENT) - v4 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/livewire (LIVEWIRE) - v3 +- laravel/mcp (MCP) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 +- tailwindcss (TAILWINDCSS) - v4 + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest {name}`. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== pest/v4 rules === + +## Pest 4 + +- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. +- Browser testing is incredibly powerful and useful for this project. +- Browser tests should live in `tests/Browser/`. +- Use the `search-docs` tool for detailed guidance on utilizing these features. + +### Browser Testing +- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. +- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. +- If requested, test on multiple browsers (Chrome, Firefox, Safari). +- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging when appropriate. + +### Example Tests + + +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); // Visit on a real browser... + + $page->assertSee('Sign In') + ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!') + + Notification::assertSent(ResetPassword::class); +}); + + + +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed. + +@theme { + --color-brand: oklch(0.72 0.11 178); +} + + +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + - @tailwind base; + - @tailwind components; + - @tailwind utilities; + + @import "tailwindcss"; + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | +
diff --git a/README.md b/README.md index 0165a77..de2e643 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,26 @@ License

+## TenantPilot setup + +- Local dev (Sail-first): + - Start stack: `./vendor/bin/sail up -d` + - Init DB: `./vendor/bin/sail artisan migrate --seed` + - Tests: `./vendor/bin/sail artisan test` + - Policy sync: `./vendor/bin/sail artisan intune:sync-policies` +- Filament admin: `/admin` (seed user `test@example.com`, set password via factory or `artisan tinker`). +- Microsoft Graph (Intune) env vars: + - `GRAPH_TENANT_ID` + - `GRAPH_CLIENT_ID` + - `GRAPH_CLIENT_SECRET` + - `GRAPH_SCOPE` (default `https://graph.microsoft.com/.default`) + - Without these, the `NullGraphClient` runs in dry mode (no Graph calls). +- Deployment (Dokploy, staging → production): + - Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline). + - Run migrations on staging first, validate backup/restore flows, then promote to production. + - Ensure queue workers are running for jobs (e.g., policy sync) after deploy. + - Keep secrets/env in Dokploy, never in code. + ## About Laravel Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: diff --git a/app/Console/Commands/SyncPolicies.php b/app/Console/Commands/SyncPolicies.php new file mode 100644 index 0000000..8546b78 --- /dev/null +++ b/app/Console/Commands/SyncPolicies.php @@ -0,0 +1,39 @@ +resolveTenant(); + $types = $this->option('types') ?: null; + + SyncPoliciesJob::dispatch($tenant->id, $types); + + $this->info("Policy sync dispatched for tenant {$tenant->graphTenantId()}"); + + return Command::SUCCESS; + } + + private function resolveTenant(): Tenant + { + $tenantId = $this->option('tenant'); + + if ($tenantId) { + return Tenant::query() + ->forTenant($tenantId) + ->firstOrFail(); + } + + return Tenant::current(); + } +} diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php new file mode 100644 index 0000000..e889d00 --- /dev/null +++ b/app/Filament/Resources/BackupSetResource.php @@ -0,0 +1,202 @@ +schema([ + Forms\Components\TextInput::make('name') + ->label('Backup name') + ->default(fn () => now()->format('Y-m-d H:i:s').' backup') + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable(), + Tables\Columns\TextColumn::make('status')->badge(), + Tables\Columns\TextColumn::make('item_count')->label('Items'), + Tables\Columns\TextColumn::make('created_by')->label('Created by'), + Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(), + Tables\Columns\TextColumn::make('created_at')->dateTime()->since(), + ]) + ->filters([ + Tables\Filters\TrashedFilter::make(), + ]) + ->actions([ + Actions\ViewAction::make() + ->url(fn (BackupSet $record) => static::getUrl('view', ['record' => $record])) + ->openUrlInNewTab(false), + ActionGroup::make([ + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (BackupSet $record) => ! $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + if ($record->restoreRuns()->withTrashed()->exists()) { + Notification::make() + ->title('Cannot archive backup set') + ->body('Backup sets used by restore runs cannot be archived.') + ->danger() + ->send(); + + return; + } + + $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(); + }), + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (BackupSet $record) => $record->trashed()) + ->action(function (BackupSet $record, AuditLogger $auditLogger) { + 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'), + ]) + ->bulkActions([]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->schema([ + Infolists\Components\TextEntry::make('name'), + Infolists\Components\TextEntry::make('status')->badge(), + Infolists\Components\TextEntry::make('item_count')->label('Items'), + Infolists\Components\TextEntry::make('created_by')->label('Created by'), + Infolists\Components\TextEntry::make('completed_at')->dateTime(), + Infolists\Components\TextEntry::make('metadata') + ->label('Metadata') + ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT)) + ->copyable() + ->copyMessage('Metadata copied'), + ]); + } + + public static function getRelations(): array + { + return [ + BackupItemsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBackupSets::route('/'), + 'create' => Pages\CreateBackupSet::route('/create'), + 'view' => Pages\ViewBackupSet::route('/{record}'), + ]; + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array + */ + private static function typeMeta(?string $type): array + { + if ($type === null) { + return []; + } + + return collect(config('tenantpilot.supported_policy_types', [])) + ->firstWhere('type', $type) ?? []; + } + + /** + * Create a backup set via the domain service instead of direct model mass-assignment. + */ + public static function createBackupSet(array $data): BackupSet + { + /** @var Tenant $tenant */ + $tenant = Tenant::current(); + + /** @var BackupService $service */ + $service = app(BackupService::class); + + return $service->createBackupSet( + tenant: $tenant, + policyIds: $data['policy_ids'] ?? [], + name: $data['name'] ?? null, + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + ); + } +} diff --git a/app/Filament/Resources/BackupSetResource/Pages/CreateBackupSet.php b/app/Filament/Resources/BackupSetResource/Pages/CreateBackupSet.php new file mode 100644 index 0000000..934336a --- /dev/null +++ b/app/Filament/Resources/BackupSetResource/Pages/CreateBackupSet.php @@ -0,0 +1,17 @@ +columns([ + Tables\Columns\TextColumn::make('policy.display_name') + ->label('Policy') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('policy_type') + ->label('Type') + ->badge() + ->formatStateUsing(fn (?string $state) => static::typeMeta($state)['label'] ?? $state), + Tables\Columns\TextColumn::make('restore_mode') + ->label('Restore') + ->badge() + ->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled') + ->color(fn (?string $state) => $state === 'preview-only' ? 'warning' : 'success'), + Tables\Columns\TextColumn::make('risk') + ->label('Risk') + ->badge() + ->state(fn (BackupItem $record) => static::typeMeta($record->policy_type)['risk'] ?? 'n/a') + ->color(fn (?string $state) => str_contains((string) $state, 'high') ? 'danger' : 'gray'), + Tables\Columns\TextColumn::make('policy_identifier') + ->label('Policy ID') + ->copyable(), + Tables\Columns\TextColumn::make('platform')->badge(), + Tables\Columns\TextColumn::make('captured_at')->dateTime(), + Tables\Columns\TextColumn::make('created_at')->since(), + ]) + ->filters([]) + ->headerActions([ + Actions\Action::make('addPolicies') + ->label('Policies hinzufügen') + ->icon('heroicon-o-plus') + ->form([ + Forms\Components\Select::make('policy_ids') + ->label('Policies') + ->multiple() + ->required() + ->searchable() + ->options(function (RelationManager $livewire) { + $backupSet = $livewire->getOwnerRecord(); + $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); + + $existing = $backupSet + ? $backupSet->items()->pluck('policy_id')->filter()->all() + : []; + + return Policy::query() + ->where('tenant_id', $tenantId) + ->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing)) + ->orderBy('display_name') + ->pluck('display_name', 'id'); + }), + ]) + ->action(function (array $data, BackupService $service) { + if (empty($data['policy_ids'])) { + Notification::make() + ->title('No policies selected') + ->warning() + ->send(); + + return; + } + + $backupSet = $this->getOwnerRecord(); + $tenant = $backupSet?->tenant ?? Tenant::current(); + + $service->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: $data['policy_ids'], + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + ); + + Notification::make() + ->title('Policies added to backup') + ->success() + ->send(); + }), + ]) + ->actions([ + Actions\ViewAction::make() + ->label('View policy') + ->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) + ->hidden(fn ($record) => ! $record->policy_id) + ->openUrlInNewTab(true), + Actions\Action::make('remove') + ->label('Remove') + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (BackupItem $record, AuditLogger $auditLogger) { + $record->delete(); + + if ($record->backupSet) { + $record->backupSet->update([ + 'item_count' => $record->backupSet->items()->count(), + ]); + } + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.item_removed', + resourceType: 'backup_set', + resourceId: (string) $record->backup_set_id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id]] + ); + } + + Notification::make() + ->title('Policy removed from backup') + ->success() + ->send(); + }), + ]) + ->bulkActions([]); + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array + */ + private static function typeMeta(?string $type): array + { + if ($type === null) { + return []; + } + + return collect(config('tenantpilot.supported_policy_types', [])) + ->firstWhere('type', $type) ?? []; + } +} diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php new file mode 100644 index 0000000..201529b --- /dev/null +++ b/app/Filament/Resources/PolicyResource.php @@ -0,0 +1,149 @@ +schema([ + Infolists\Components\TextEntry::make('display_name')->label('Policy'), + Infolists\Components\TextEntry::make('policy_type')->label('Type'), + Infolists\Components\TextEntry::make('platform'), + Infolists\Components\TextEntry::make('external_id')->label('External ID'), + Infolists\Components\TextEntry::make('last_synced_at')->dateTime()->label('Last synced'), + Infolists\Components\TextEntry::make('created_at')->since(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('display_name') + ->label('Policy') + ->searchable(), + Tables\Columns\TextColumn::make('policy_type') + ->label('Type') + ->badge() + ->formatStateUsing(fn (?string $state, Policy $record) => static::typeMeta($record->policy_type)['label'] ?? $state), + Tables\Columns\TextColumn::make('category') + ->label('Category') + ->badge() + ->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? 'Unknown'), + Tables\Columns\TextColumn::make('restore_mode') + ->label('Restore') + ->badge() + ->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'), + Tables\Columns\TextColumn::make('platform') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('external_id') + ->label('External ID') + ->copyable() + ->limit(32), + Tables\Columns\TextColumn::make('last_synced_at') + ->label('Last synced') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->since() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('policy_type') + ->options(fn () => Policy::query()->distinct()->pluck('policy_type', 'policy_type')->all()), + Tables\Filters\SelectFilter::make('category') + ->options(function () { + return collect(config('tenantpilot.supported_policy_types', [])) + ->pluck('category', 'category') + ->filter() + ->unique() + ->sort() + ->all(); + }) + ->query(function (Builder $query, array $data) { + $category = $data['value'] ?? null; + if (! $category) { + return; + } + + $types = collect(config('tenantpilot.supported_policy_types', [])) + ->where('category', $category) + ->pluck('type') + ->all(); + + $query->whereIn('policy_type', $types); + }), + Tables\Filters\SelectFilter::make('platform') + ->options(fn () => Policy::query()->distinct()->pluck('platform', 'platform')->filter()->all()), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); + } + + public static function getRelations(): array + { + return [ + VersionsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPolicies::route('/'), + 'view' => Pages\ViewPolicy::route('/{record}'), + ]; + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array|array + */ + private static function typeMeta(?string $type): array + { + if ($type === null) { + return []; + } + + return collect(config('tenantpilot.supported_policy_types', [])) + ->firstWhere('type', $type) ?? []; + } +} diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php new file mode 100644 index 0000000..601b9c0 --- /dev/null +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -0,0 +1,48 @@ +label('Sync from Intune') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->action(function () { + try { + $tenant = Tenant::current(); + + /** @var PolicySyncService $service */ + $service = app(PolicySyncService::class); + + $synced = $service->syncPolicies($tenant); + + Notification::make() + ->title('Policy sync completed') + ->body(count($synced).' policies synced') + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title('Policy sync failed') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]; + } +} diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php new file mode 100644 index 0000000..66742e2 --- /dev/null +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -0,0 +1,11 @@ +columns([ + Tables\Columns\TextColumn::make('version_number')->sortable(), + Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), + Tables\Columns\TextColumn::make('created_by')->label('Actor'), + Tables\Columns\TextColumn::make('policy_type')->badge()->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('version_number', 'desc') + ->filters([]) + ->headerActions([]) + ->actions([ + Actions\ViewAction::make() + ->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record])) + ->openUrlInNewTab(false), + ]) + ->bulkActions([]); + } +} diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php new file mode 100644 index 0000000..8f5b4df --- /dev/null +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -0,0 +1,138 @@ +schema([ + Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'), + Infolists\Components\TextEntry::make('version_number')->label('Version'), + Infolists\Components\TextEntry::make('policy_type'), + Infolists\Components\TextEntry::make('platform'), + Infolists\Components\TextEntry::make('created_by')->label('Actor'), + Infolists\Components\TextEntry::make('captured_at')->dateTime(), + Infolists\Components\TextEntry::make('snapshot') + ->label('Snapshot') + ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT)) + ->copyable(), + Infolists\Components\TextEntry::make('diff') + ->label('Diff vs previous') + ->state(function (PolicyVersion $record) { + $previous = $record->previous(); + + if (! $previous) { + return ['summary' => 'No previous version']; + } + + return app(\App\Services\Intune\VersionDiff::class) + ->compare($previous->snapshot ?? [], $record->snapshot ?? []); + }) + ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT)) + ->copyable(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(), + Tables\Columns\TextColumn::make('version_number')->sortable(), + Tables\Columns\TextColumn::make('policy_type')->badge(), + Tables\Columns\TextColumn::make('platform')->badge(), + Tables\Columns\TextColumn::make('created_by')->label('Actor'), + Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), + ]) + ->filters([ + Tables\Filters\TrashedFilter::make(), + ]) + ->actions([ + Actions\ViewAction::make() + ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) + ->openUrlInNewTab(false), + Actions\ActionGroup::make([ + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => ! $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $record->delete(); + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.deleted', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } + + Notification::make() + ->title('Policy version archived') + ->success() + ->send(); + }), + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (PolicyVersion $record) => $record->trashed()) + ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'policy_version.force_deleted', + resourceType: 'policy_version', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]] + ); + } + + $record->forceDelete(); + + Notification::make() + ->title('Policy version permanently deleted') + ->success() + ->send(); + }), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPolicyVersions::route('/'), + 'view' => Pages\ViewPolicyVersion::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php b/app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php new file mode 100644 index 0000000..b951248 --- /dev/null +++ b/app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php @@ -0,0 +1,16 @@ +schema([ + Forms\Components\Select::make('backup_set_id') + ->label('Backup set') + ->options(function () { + $tenantId = Tenant::current()->getKey(); + + return BackupSet::query() + ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) + ->orderByDesc('created_at') + ->get() + ->mapWithKeys(function (BackupSet $set) { + $label = sprintf( + '%s • %s items • %s', + $set->name, + $set->item_count ?? 0, + optional($set->created_at)->format('Y-m-d H:i') + ); + + return [$set->id => $label]; + }); + }) + ->reactive() + ->required(), + Forms\Components\CheckboxList::make('backup_item_ids') + ->label('Items to restore (optional)') + ->options(function (Get $get) { + $backupSetId = $get('backup_set_id'); + if (! $backupSetId) { + return []; + } + + return BackupItem::query() + ->where('backup_set_id', $backupSetId) + ->whereHas('backupSet', function ($query) { + $tenantId = Tenant::current()->getKey(); + $query->where('tenant_id', $tenantId); + }) + ->get() + ->mapWithKeys(function (BackupItem $item) { + $meta = static::typeMeta($item->policy_type); + $typeLabel = $meta['label'] ?? $item->policy_type; + $restore = $meta['restore'] ?? 'enabled'; + + $label = sprintf( + '%s (%s • restore: %s)', + $item->policy_identifier ?? $item->policy_type, + $typeLabel, + $restore + ); + + return [$item->id => $label]; + }); + }) + ->columns(2) + ->helperText('Preview-only types stay in dry-run; leave empty to include all items.'), + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'), + Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(), + Tables\Columns\TextColumn::make('status')->badge(), + Tables\Columns\TextColumn::make('started_at')->dateTime()->since(), + Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(), + Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), + ]) + ->filters([ + Tables\Filters\TrashedFilter::make(), + ]) + ->actions([ + Actions\ViewAction::make(), + ActionGroup::make([ + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record) => ! $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $record->delete(); + + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.deleted', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } + + Notification::make() + ->title('Restore run archived') + ->success() + ->send(); + }), + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (RestoreRun $record) => $record->trashed()) + ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'restore_run.force_deleted', + resourceType: 'restore_run', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['backup_set_id' => $record->backup_set_id]] + ); + } + + $record->forceDelete(); + + Notification::make() + ->title('Restore run permanently deleted') + ->success() + ->send(); + }), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->schema([ + Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'), + Infolists\Components\TextEntry::make('status')->badge(), + Infolists\Components\TextEntry::make('is_dry_run') + ->label('Dry-run') + ->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No') + ->badge(), + Infolists\Components\TextEntry::make('requested_by'), + Infolists\Components\TextEntry::make('started_at')->dateTime(), + Infolists\Components\TextEntry::make('completed_at')->dateTime(), + Infolists\Components\TextEntry::make('preview') + ->label('Preview') + ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT)) + ->copyable(), + Infolists\Components\TextEntry::make('results') + ->label('Results') + ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT)) + ->copyable(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListRestoreRuns::route('/'), + 'create' => Pages\CreateRestoreRun::route('/create'), + 'view' => Pages\ViewRestoreRun::route('/{record}'), + ]; + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array + */ + private static function typeMeta(?string $type): array + { + if ($type === null) { + return []; + } + + return collect(config('tenantpilot.supported_policy_types', [])) + ->firstWhere('type', $type) ?? []; + } + + public static function createRestoreRun(array $data): RestoreRun + { + /** @var Tenant $tenant */ + $tenant = Tenant::current(); + + /** @var BackupSet $backupSet */ + $backupSet = BackupSet::findOrFail($data['backup_set_id']); + + if ($backupSet->tenant_id !== $tenant->id) { + abort(403, 'Backup set does not belong to the active tenant.'); + } + + /** @var RestoreService $service */ + $service = app(RestoreService::class); + + return $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $data['backup_item_ids'] ?? null, + dryRun: (bool) ($data['is_dry_run'] ?? true), + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + ); + } +} diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php new file mode 100644 index 0000000..2c22eb1 --- /dev/null +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -0,0 +1,17 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('tenant_id') + ->label('Tenant ID (GUID)') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + Forms\Components\TextInput::make('domain') + ->label('Primary domain') + ->maxLength(255), + Forms\Components\TextInput::make('app_client_id') + ->label('App Client ID') + ->maxLength(255), + Forms\Components\TextInput::make('app_client_secret') + ->label('App Client Secret') + ->password() + ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) + ->dehydrated(fn ($state) => filled($state)), + Forms\Components\TextInput::make('app_certificate_thumbprint') + ->label('Certificate thumbprint') + ->maxLength(255), + Forms\Components\Textarea::make('app_notes') + ->label('Notes') + ->rows(3), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->modifyQueryUsing(fn (\Illuminate\Database\Eloquent\Builder $query) => $query->withTrashed()) + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable(), + Tables\Columns\TextColumn::make('tenant_id') + ->label('Tenant ID') + ->copyable() + ->searchable(), + Tables\Columns\TextColumn::make('domain') + ->copyable() + ->toggleable(), + Tables\Columns\IconColumn::make('is_current') + ->label('Current') + ->boolean(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('app_status') + ->badge(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->since(), + ]) + ->filters([ + Tables\Filters\TrashedFilter::make() + ->label('Archive filter') + ->placeholder('Active only') + ->trueLabel('Active + archived') + ->falseLabel('Archived only') + ->default(true), + Tables\Filters\SelectFilter::make('app_status') + ->options([ + 'ok' => 'OK', + 'consent_required' => 'Consent required', + 'error' => 'Error', + 'unknown' => 'Unknown', + ]), + ]) + ->actions([ + Actions\ViewAction::make(), + ActionGroup::make([ + Actions\EditAction::make(), + Actions\RestoreAction::make() + ->label('Restore') + ->color('success') + ->successNotificationTitle('Tenant reactivated') + ->after(function (Tenant $record, AuditLogger $auditLogger) { + $auditLogger->log( + tenant: $record, + action: 'tenant.restored', + resourceType: 'tenant', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $record->tenant_id]] + ); + }), + Actions\Action::make('makeCurrent') + ->label('Make current') + ->color('success') + ->icon('heroicon-o-check-circle') + ->requiresConfirmation() + ->visible(fn (Tenant $record) => $record->isActive() && ! $record->is_current) + ->action(function (Tenant $record, AuditLogger $auditLogger) { + $record->makeCurrent(); + + $auditLogger->log( + tenant: $record, + action: 'tenant.current_set', + resourceType: 'tenant', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $record->tenant_id]] + ); + + Notification::make() + ->title('Current tenant updated') + ->success() + ->send(); + }), + Actions\Action::make('admin_consent') + ->label('Admin consent') + ->icon('heroicon-o-clipboard-document') + ->url(fn (Tenant $record) => static::adminConsentUrl($record)) + ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('verify') + ->label('Verify configuration') + ->icon('heroicon-o-check-badge') + ->color('primary') + ->requiresConfirmation() + ->action(function ( + Tenant $record, + TenantConfigService $configService, + TenantPermissionService $permissionService, + AuditLogger $auditLogger + ) { + static::verifyTenant($record, $configService, $permissionService, $auditLogger); + }), + Actions\Action::make('archive') + ->label('Deactivate') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (Tenant $record) => ! $record->trashed()) + ->action(function (Tenant $record, AuditLogger $auditLogger) { + $record->delete(); + + $auditLogger->log( + tenant: $record, + action: 'tenant.archived', + resourceType: 'tenant', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $record->tenant_id]] + ); + + Notification::make() + ->title('Tenant deactivated') + ->body('The tenant has been archived and hidden from lists.') + ->success() + ->send(); + }), + Actions\Action::make('forceDelete') + ->label('Force delete') + ->color('danger') + ->icon('heroicon-o-trash') + ->requiresConfirmation() + ->visible(fn (?Tenant $record) => $record?->trashed()) + ->action(function (?Tenant $record, AuditLogger $auditLogger) { + if ($record === null) { + return; + } + + $tenant = Tenant::withTrashed()->find($record->id); + + if (! $tenant?->trashed()) { + Notification::make() + ->title('Tenant must be archived first') + ->danger() + ->send(); + + return; + } + + $auditLogger->log( + tenant: $tenant, + action: 'tenant.force_deleted', + resourceType: 'tenant', + resourceId: (string) $tenant->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $tenant->tenant_id]] + ); + + $tenant->forceDelete(); + + Notification::make() + ->title('Tenant permanently deleted') + ->success() + ->send(); + }), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([]) + ->headerActions([]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->schema([ + Infolists\Components\TextEntry::make('name'), + Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(), + Infolists\Components\TextEntry::make('domain')->copyable(), + Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(), + Infolists\Components\TextEntry::make('status')->badge(), + Infolists\Components\TextEntry::make('app_status')->badge(), + Infolists\Components\TextEntry::make('app_notes')->label('Notes'), + Infolists\Components\TextEntry::make('created_at')->dateTime(), + Infolists\Components\TextEntry::make('updated_at')->dateTime(), + Infolists\Components\TextEntry::make('admin_consent_url') + ->label('Admin consent URL') + ->state(fn (Tenant $record) => static::adminConsentUrl($record)) + ->visible(fn (?string $state) => filled($state)) + ->copyable(), + Infolists\Components\RepeatableEntry::make('permissions') + ->label('Required permissions') + ->state(fn (Tenant $record) => app(TenantPermissionService::class)->compare($record, persist: false)['permissions']) + ->schema([ + Infolists\Components\TextEntry::make('key')->label('Permission')->badge(), + Infolists\Components\TextEntry::make('type')->badge(), + Infolists\Components\TextEntry::make('features') + ->label('Features') + ->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state), + Infolists\Components\TextEntry::make('status') + ->badge(), + ]) + ->columnSpanFull(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTenants::route('/'), + 'create' => Pages\CreateTenant::route('/create'), + 'view' => Pages\ViewTenant::route('/{record}'), + 'edit' => Pages\EditTenant::route('/{record}/edit'), + ]; + } + + public static function adminConsentUrl(Tenant $tenant): ?string + { + $tenantId = $tenant->graphTenantId(); + $clientId = $tenant->app_client_id; + $redirectUri = route('admin.consent.callback'); + $scope = config('graph.scope') ?: 'https://graph.microsoft.com/.default'; + $state = sprintf('tenantpilot|%s', $tenant->id); + + if (! $tenantId || ! $clientId || ! $redirectUri) { + return null; + } + + $query = http_build_query([ + 'client_id' => $clientId, + 'state' => $state, + 'redirect_uri' => $redirectUri, + 'scope' => $scope, + ]); + + return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query); + } + + public static function entraUrl(Tenant $tenant): ?string + { + if ($tenant->app_client_id) { + return sprintf( + 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/%s', + $tenant->app_client_id + ); + } + + if ($tenant->graphTenantId()) { + return sprintf( + 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview/tenantId/%s', + $tenant->graphTenantId() + ); + } + + return null; + } + + public static function verifyTenant( + Tenant $tenant, + TenantConfigService $configService, + TenantPermissionService $permissionService, + AuditLogger $auditLogger + ): void { + $configResult = $configService->testConnectivity($tenant); + + $permissionStatuses = []; + foreach ($permissionService->getRequiredPermissions() as $permission) { + $permissionStatuses[$permission['key']] = [ + 'status' => $configResult['success'] ? 'ok' : 'error', + 'details' => $configResult['success'] + ? null + : ['message' => $configResult['error_message']], + ]; + } + + $permissions = $permissionService->compare($tenant, $permissionStatuses); + + $appStatus = $configResult['success'] + ? 'ok' + : ($configResult['requires_consent'] ? 'consent_required' : 'error'); + + $tenant->update([ + 'app_status' => $appStatus, + 'app_notes' => $configResult['error_message'], + ]); + + $user = auth()->user(); + + $auditLogger->log( + tenant: $tenant, + action: 'tenant.config.verified', + context: [ + 'metadata' => [ + 'app_status' => $appStatus, + 'error' => $configResult['error_message'], + ], + ], + actorId: $user?->id, + actorEmail: $user?->email, + actorName: $user?->name, + status: $appStatus === 'ok' ? 'success' : 'error', + resourceType: 'tenant', + resourceId: (string) $tenant->id, + ); + + $auditLogger->log( + tenant: $tenant, + action: 'tenant.permissions.checked', + context: [ + 'metadata' => [ + 'overall_status' => $permissions['overall_status'], + ], + ], + actorId: $user?->id, + actorEmail: $user?->email, + actorName: $user?->name, + status: match ($permissions['overall_status']) { + 'ok' => 'success', + 'error' => 'error', + default => 'partial', + }, + resourceType: 'tenant', + resourceId: (string) $tenant->id, + ); + + $notification = Notification::make() + ->title($configResult['success'] ? 'Configuration verified' : 'Verification failed') + ->body($configResult['success'] + ? 'Graph connectivity confirmed. Permission status: '.$permissions['overall_status'] + : ($configResult['error_message'] ?? 'Graph connectivity failed')); + + if ($configResult['success']) { + $notification->success(); + } elseif ($configResult['requires_consent']) { + $notification->warning(); + } else { + $notification->danger(); + } + + $notification->send(); + } +} diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php new file mode 100644 index 0000000..2a0c9ed --- /dev/null +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -0,0 +1,11 @@ +label('Admin consent') + ->icon('heroicon-o-clipboard-document') + ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) + ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('open_in_entra') + ->label('Open in Entra') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Tenant $record) => TenantResource::entraUrl($record)) + ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('verify') + ->label('Verify configuration') + ->icon('heroicon-o-check-badge') + ->color('primary') + ->requiresConfirmation() + ->action(function ( + Tenant $record, + TenantConfigService $configService, + TenantPermissionService $permissionService, + AuditLogger $auditLogger + ) { + TenantResource::verifyTenant($record, $configService, $permissionService, $auditLogger); + }), + ]; + } +} diff --git a/app/Http/Controllers/AdminConsentCallbackController.php b/app/Http/Controllers/AdminConsentCallbackController.php new file mode 100644 index 0000000..28f2f49 --- /dev/null +++ b/app/Http/Controllers/AdminConsentCallbackController.php @@ -0,0 +1,240 @@ +session()->pull('tenant_onboard_state'); + $tenantKey = $request->string('tenant')->toString(); + $state = $request->string('state')->toString(); + $tenantIdentifier = $tenantKey ?: $this->parseState($state); + + if ($expectedState && $expectedState !== $state) { + abort(ResponseAlias::HTTP_FORBIDDEN, 'Invalid consent state'); + } + + abort_if(empty($tenantIdentifier), 404); + + $tenant = Tenant::withTrashed() + ->forTenant($tenantIdentifier) + ->first(); + + if ($tenant?->trashed()) { + $tenant->restore(); + } + + if (! $tenant) { + $tenant = Tenant::create([ + 'tenant_id' => $tenantIdentifier, + 'name' => 'New Tenant', + 'app_client_id' => config('graph.client_id'), + 'app_client_secret' => config('graph.client_secret'), + 'app_status' => 'pending', + ]); + } + + $error = $request->string('error')->toString() ?: null; + $consentGranted = $request->has('admin_consent') + ? filter_var($request->input('admin_consent'), FILTER_VALIDATE_BOOLEAN) + : null; + + $status = match (true) { + $error !== null => 'error', + $consentGranted === false => 'consent_denied', + $consentGranted === true => 'ok', + default => 'pending', + }; + + $tenant->update([ + 'app_status' => $status, + 'app_notes' => $error, + ]); + + $auditLogger->log( + tenant: $tenant, + action: 'tenant.consent.callback', + context: [ + 'metadata' => [ + 'status' => $status, + 'state' => $state, + 'error' => $error, + 'consent' => $consentGranted, + ], + ], + status: $status === 'ok' ? 'success' : 'error', + resourceType: 'tenant', + resourceId: (string) $tenant->id, + ); + + return view('admin-consent-callback', [ + 'tenant' => $tenant, + 'status' => $status, + 'error' => $error, + 'consentGranted' => $consentGranted, + ]); + } + + private function handleAuthorizationCodeFlow( + Request $request, + AuditLogger $auditLogger, + TenantConfigService $configService, + TenantPermissionService $permissionService, + GraphClientInterface $graphClient + ): View { + $expectedState = $request->session()->pull('tenant_onboard_state'); + if ($expectedState && $expectedState !== $request->string('state')->toString()) { + abort(ResponseAlias::HTTP_FORBIDDEN, 'Invalid consent state'); + } + + $redirectUri = route('admin.consent.callback'); + + $token = $this->exchangeAuthorizationCode( + code: $request->string('code')->toString(), + redirectUri: $redirectUri + ); + + $tenantId = $token['tenant_id'] ?? null; + abort_if(empty($tenantId), 500, 'Tenant ID missing from token'); + + /** @var Tenant|null $tenant */ + $tenant = Tenant::withTrashed() + ->forTenant($tenantId) + ->first(); + + if ($tenant?->trashed()) { + $tenant->restore(); + } + + if (! $tenant) { + $tenant = Tenant::create([ + 'tenant_id' => $tenantId, + 'name' => 'New Tenant', + 'app_client_id' => config('graph.client_id'), + 'app_client_secret' => config('graph.client_secret'), + 'app_status' => 'pending', + ]); + } + + $orgResponse = $graphClient->getOrganization([ + 'tenant' => $tenant->graphTenantId(), + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]); + + if ($orgResponse->successful()) { + $org = $orgResponse->data ?? []; + $tenant->update([ + 'name' => $org['displayName'] ?? $tenant->name, + 'domain' => $org['verifiedDomains'][0]['name'] ?? $tenant->domain, + ]); + } + + $configResult = $configService->testConnectivity($tenant); + $permissionService->compare($tenant); + + $status = $configResult['success'] ? 'ok' : 'error'; + + $tenant->update([ + 'app_status' => $status, + 'app_notes' => $configResult['error_message'], + ]); + + $auditLogger->log( + tenant: $tenant, + action: 'tenant.consent.callback', + context: [ + 'metadata' => [ + 'status' => $status, + 'error' => $configResult['error_message'], + 'from' => 'authorization_code', + ], + ], + status: $status === 'ok' ? 'success' : 'error', + resourceType: 'tenant', + resourceId: (string) $tenant->id, + ); + + return view('admin-consent-callback', [ + 'tenant' => $tenant, + 'status' => $status, + 'error' => $configResult['error_message'], + 'consentGranted' => $status === 'ok', + ]); + } + + /** + * @return array{access_token:string,id_token:string,tenant_id:?string} + */ + private function exchangeAuthorizationCode(string $code, string $redirectUri): array + { + $response = Http::asForm()->post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [ + 'client_id' => config('graph.client_id'), + 'client_secret' => config('graph.client_secret'), + 'code' => $code, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $redirectUri, + 'scope' => 'https://graph.microsoft.com/.default offline_access openid profile', + ]); + + if ($response->failed()) { + abort(ResponseAlias::HTTP_BAD_GATEWAY, 'Failed to exchange code for token'); + } + + $body = $response->json(); + $idToken = $body['id_token'] ?? null; + $tenantId = $this->parseTenantIdFromToken($idToken); + + return [ + 'access_token' => $body['access_token'] ?? '', + 'id_token' => $idToken ?? '', + 'tenant_id' => $tenantId, + ]; + } + + private function parseTenantIdFromToken(?string $token): ?string + { + if (! $token || ! str_contains($token, '.')) { + return null; + } + + $parts = explode('.', $token); + $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')) ?: '[]', true); + + return $payload['tid'] ?? $payload['tenant'] ?? null; + } + + private function parseState(?string $state): ?string + { + if (empty($state)) { + return null; + } + + if (str_contains($state, '|')) { + [, $value] = explode('|', $state, 2); + + return $value; + } + + return $state; + } +} diff --git a/app/Http/Controllers/TenantOnboardingController.php b/app/Http/Controllers/TenantOnboardingController.php new file mode 100644 index 0000000..62ee725 --- /dev/null +++ b/app/Http/Controllers/TenantOnboardingController.php @@ -0,0 +1,32 @@ +string('tenant')->toString() ?: config('graph.tenant_id', 'organizations'); + $tenantSegment = $targetTenant ?: 'organizations'; + + abort_if(empty($clientId) || empty($redirectUri), 500, 'Graph client not configured'); + + $state = Str::uuid()->toString(); + $request->session()->put('tenant_onboard_state', $state); + + $url = "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'scope' => 'https://graph.microsoft.com/.default', + 'state' => $state, + ]); + + return redirect()->away($url); + } +} diff --git a/app/Jobs/SyncPoliciesJob.php b/app/Jobs/SyncPoliciesJob.php new file mode 100644 index 0000000..81df8f2 --- /dev/null +++ b/app/Jobs/SyncPoliciesJob.php @@ -0,0 +1,37 @@ +|null $types + */ + public function __construct( + public readonly int $tenantId, + public readonly ?array $types = null, + ) {} + + public function handle(PolicySyncService $service): void + { + $tenant = Tenant::findOrFail($this->tenantId); + + $supported = config('tenantpilot.supported_policy_types'); + + if ($this->types !== null) { + $supported = array_values(array_filter($supported, fn ($type) => in_array($type['type'], $this->types, true))); + } + + $service->syncPolicies($tenant, $supported); + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 0000000..a29a038 --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,24 @@ + 'array', + 'recorded_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/BackupItem.php b/app/Models/BackupItem.php new file mode 100644 index 0000000..6574131 --- /dev/null +++ b/app/Models/BackupItem.php @@ -0,0 +1,37 @@ + 'array', + 'metadata' => 'array', + 'captured_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function backupSet(): BelongsTo + { + return $this->belongsTo(BackupSet::class); + } + + public function policy(): BelongsTo + { + return $this->belongsTo(Policy::class); + } +} diff --git a/app/Models/BackupSet.php b/app/Models/BackupSet.php new file mode 100644 index 0000000..c6757b8 --- /dev/null +++ b/app/Models/BackupSet.php @@ -0,0 +1,48 @@ + 'array', + 'completed_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function items(): HasMany + { + return $this->hasMany(BackupItem::class); + } + + public function restoreRuns(): HasMany + { + return $this->hasMany(RestoreRun::class); + } + + protected static function booted(): void + { + static::deleting(function (BackupSet $backupSet) { + if ($backupSet->isForceDeleting()) { + return; + } + + $backupSet->items()->delete(); + }); + } +} diff --git a/app/Models/Policy.php b/app/Models/Policy.php new file mode 100644 index 0000000..a9667cc --- /dev/null +++ b/app/Models/Policy.php @@ -0,0 +1,35 @@ + 'array', + 'last_synced_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function versions(): HasMany + { + return $this->hasMany(PolicyVersion::class); + } + + public function backupItems(): HasMany + { + return $this->hasMany(BackupItem::class); + } +} diff --git a/app/Models/PolicyVersion.php b/app/Models/PolicyVersion.php new file mode 100644 index 0000000..ef8ef6e --- /dev/null +++ b/app/Models/PolicyVersion.php @@ -0,0 +1,43 @@ + 'array', + 'metadata' => 'array', + 'captured_at' => 'datetime', + ]; + + public function previous(): ?self + { + return $this->policy + ? $this->policy + ->versions() + ->where('version_number', '<', $this->version_number) + ->orderByDesc('version_number') + ->first() + : null; + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function policy(): BelongsTo + { + return $this->belongsTo(Policy::class); + } +} diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php new file mode 100644 index 0000000..cd07138 --- /dev/null +++ b/app/Models/RestoreRun.php @@ -0,0 +1,36 @@ + 'boolean', + 'requested_items' => 'array', + 'preview' => 'array', + 'results' => 'array', + 'metadata' => 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function backupSet(): BelongsTo + { + return $this->belongsTo(BackupSet::class); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php new file mode 100644 index 0000000..b85782b --- /dev/null +++ b/app/Models/Tenant.php @@ -0,0 +1,175 @@ + 'array', + 'app_client_secret' => 'encrypted', + 'is_current' => 'boolean', + ]; + + protected static function booted(): void + { + static::creating(function (Tenant $tenant) { + if (empty($tenant->external_id)) { + $tenant->external_id = $tenant->tenant_id ?? (string) Str::uuid(); + } + + if (empty($tenant->status)) { + $tenant->status = 'active'; + } + }); + + static::saving(function (Tenant $tenant) { + if (! empty($tenant->tenant_id)) { + $tenant->external_id = $tenant->tenant_id; + } + }); + + static::deleting(function (Tenant $tenant) { + if ($tenant->isForceDeleting()) { + return; + } + + $tenant->status = 'archived'; + $tenant->saveQuietly(); + }); + + static::restored(function (Tenant $tenant) { + $tenant->forceFill(['status' => 'active'])->saveQuietly(); + }); + } + + public static function activeQuery(): Builder + { + return static::query() + ->whereNull('deleted_at') + ->where('status', 'active'); + } + + public function makeCurrent(): void + { + if ($this->trashed() || $this->status !== 'active') { + throw new RuntimeException('Only active tenants can be made current.'); + } + + DB::transaction(function () { + static::activeQuery()->update(['is_current' => false]); + + $this->forceFill(['is_current' => true])->save(); + }); + } + + public static function current(): self + { + $envTenantId = env('INTUNE_TENANT_ID') ?: null; + + if ($envTenantId) { + $tenant = static::activeQuery() + ->where(function (Builder $query) use ($envTenantId) { + $query->where('tenant_id', $envTenantId) + ->orWhere('external_id', $envTenantId); + }) + ->first(); + + if (! $tenant) { + throw new RuntimeException('Configured INTUNE_TENANT_ID tenant is missing or inactive.'); + } + + return $tenant; + } + + $tenant = static::activeQuery() + ->where('is_current', true) + ->first(); + + if (! $tenant) { + throw new RuntimeException('No current tenant selected.'); + } + + return $tenant; + } + + public function policies(): HasMany + { + return $this->hasMany(Policy::class); + } + + public function backupSets(): HasMany + { + return $this->hasMany(BackupSet::class); + } + + public function policyVersions(): HasMany + { + return $this->hasMany(PolicyVersion::class); + } + + public function restoreRuns(): HasMany + { + return $this->hasMany(RestoreRun::class); + } + + public function auditLogs(): HasMany + { + return $this->hasMany(AuditLog::class); + } + + public function permissions(): HasMany + { + return $this->hasMany(TenantPermission::class); + } + + public function graphTenantId(): ?string + { + return $this->tenant_id ?? $this->external_id; + } + + /** + * @return array{tenant:?string,client_id:?string,client_secret:?string} + */ + public function graphOptions(): array + { + return [ + 'tenant' => $this->graphTenantId(), + 'client_id' => $this->app_client_id, + 'client_secret' => $this->app_client_secret, + ]; + } + + public function scopeForTenant(Builder $query, self|int|string $tenant): Builder + { + if ($tenant instanceof self) { + return $query->whereKey($tenant->getKey()); + } + + if (is_int($tenant) || ctype_digit((string) $tenant)) { + return $query->whereKey($tenant); + } + + return $query + ->where('tenant_id', $tenant) + ->orWhere('external_id', $tenant); + } + + public function isActive(): bool + { + return ! $this->trashed() && ($this->status ?? 'active') === 'active'; + } +} diff --git a/app/Models/TenantPermission.php b/app/Models/TenantPermission.php new file mode 100644 index 0000000..da85161 --- /dev/null +++ b/app/Models/TenantPermission.php @@ -0,0 +1,24 @@ + 'array', + 'last_checked_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..ddf23da 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,12 +2,13 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use Filament\Models\Contracts\FilamentUser; +use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -class User extends Authenticatable +class User extends Authenticatable implements FilamentUser { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable; @@ -45,4 +46,9 @@ protected function casts(): array 'password' => 'hashed', ]; } + + public function canAccessPanel(Panel $panel): bool + { + return true; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..d691c7b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Services\Graph\GraphClientInterface; +use App\Services\Graph\MicrosoftGraphClient; +use App\Services\Graph\NullGraphClient; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +14,19 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(GraphClientInterface::class, function ($app) { + $config = $app['config']->get('graph'); + + $hasCredentials = ! empty($config['client_id']) + && ! empty($config['client_secret']) + && ! empty($config['tenant_id']); + + if (! empty($config['enabled']) && $hasCredentials) { + return $app->make(MicrosoftGraphClient::class); + } + + return $app->make(NullGraphClient::class); + }); } /** diff --git a/app/Services/Graph/GraphClientInterface.php b/app/Services/Graph/GraphClientInterface.php new file mode 100644 index 0000000..f02ff71 --- /dev/null +++ b/app/Services/Graph/GraphClientInterface.php @@ -0,0 +1,29 @@ +getCode() ?: 0); + + return new GraphException( + $throwable->getMessage(), + $code > 0 ? $code : null, + $context + ['exception' => get_class($throwable)] + ); + } +} diff --git a/app/Services/Graph/GraphException.php b/app/Services/Graph/GraphException.php new file mode 100644 index 0000000..f85b169 --- /dev/null +++ b/app/Services/Graph/GraphException.php @@ -0,0 +1,16 @@ + $action] + $context); + } + + public function logResponse(string $action, GraphResponse $response, array $context = []): void + { + Log::info('graph.response', [ + 'action' => $action, + 'status' => $response->status, + 'success' => $response->success, + 'warnings' => $response->warnings, + ] + $context); + } +} diff --git a/app/Services/Graph/GraphResponse.php b/app/Services/Graph/GraphResponse.php new file mode 100644 index 0000000..c44c3d2 --- /dev/null +++ b/app/Services/Graph/GraphResponse.php @@ -0,0 +1,24 @@ +success; + } + + public function failed(): bool + { + return $this->success === false; + } +} diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php new file mode 100644 index 0000000..68da5d5 --- /dev/null +++ b/app/Services/Graph/MicrosoftGraphClient.php @@ -0,0 +1,315 @@ +baseUrl = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/') + .'/'.trim(config('graph.version', 'beta'), '/'); + $this->tokenUrlTemplate = config('graph.token_url', 'https://login.microsoftonline.com/%s/oauth2/v2.0/token'); + $this->tenantId = config('graph.tenant_id', ''); + $this->clientId = config('graph.client_id', ''); + $this->clientSecret = config('graph.client_secret', ''); + $this->defaultScopes = $this->normalizeScopes(config('graph.scope', self::DEFAULT_SCOPE)); + $this->timeout = (int) config('graph.timeout', 10); + $this->retryTimes = (int) config('graph.retry.times', 2); + $this->retrySleepMs = (int) config('graph.retry.sleep', 200); + } + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $endpoint = $this->endpointFor($policyType); + $query = array_filter([ + '$top' => $options['top'] ?? null, + '$filter' => $options['filter'] ?? null, + 'platform' => $options['platform'] ?? null, + ], fn ($value) => $value !== null && $value !== ''); + + $context = $this->resolveContext($options); + + $this->logger->logRequest('list_policies', [ + 'endpoint' => $endpoint, + 'policy_type' => $policyType, + 'tenant' => $context['tenant'], + ]); + + $response = $this->send('GET', $endpoint, ['query' => $query], $context); + + return $this->toGraphResponse( + action: 'list_policies', + response: $response, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []) + ); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId); + $query = array_filter([ + '$select' => $options['select'] ?? null, + ], fn ($value) => $value !== null && $value !== ''); + + $context = $this->resolveContext($options); + + $this->logger->logRequest('get_policy', [ + 'endpoint' => $endpoint, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'tenant' => $context['tenant'], + ]); + + $response = $this->send('GET', $endpoint, ['query' => $query], $context); + + return $this->toGraphResponse( + action: 'get_policy', + response: $response, + transform: fn (array $json) => ['payload' => $json] + ); + } + + public function getOrganization(array $options = []): GraphResponse + { + $context = $this->resolveContext($options); + $endpoint = 'organization'; + + $this->logger->logRequest('get_organization', [ + 'endpoint' => $endpoint, + 'tenant' => $context['tenant'], + ]); + + $response = $this->send('GET', $endpoint, [], $context); + + return $this->toGraphResponse( + action: 'get_organization', + response: $response, + transform: fn (array $json) => $json['value'][0] ?? $json + ); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId); + $method = strtoupper($options['method'] ?? 'PATCH'); + + $context = $this->resolveContext($options); + + $this->logger->logRequest('apply_policy', [ + 'endpoint' => $endpoint, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'tenant' => $context['tenant'], + ]); + + $response = $this->send($method, $endpoint, ['json' => $payload], $context); + + return $this->toGraphResponse( + action: 'apply_policy', + response: $response, + transform: fn (array $json) => $json + ); + } + + private function send(string $method, string $path, array $options = [], array $context = []): Response + { + $context = $context ?: $this->resolveContext([]); + $token = $this->getAccessToken($context); + + $pending = Http::baseUrl($this->baseUrl) + ->acceptJson() + ->timeout($this->timeout) + ->retry($this->retryTimes, $this->retrySleepMs) + ->withToken($token); + + if (! empty($options['query'])) { + $pending = $pending->withQueryParameters($options['query']); + } + + try { + $response = $pending->send( + $method, + ltrim($path, '/'), + isset($options['json']) ? ['json' => $options['json']] : [] + ); + } catch (ConnectionException $exception) { + throw new GraphException( + 'Graph connection failed: '.$exception->getMessage(), + null, + ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null] + ); + } catch (Throwable $throwable) { + throw GraphErrorMapper::fromThrowable( + $throwable, + ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null] + ); + } + + $this->logger->logResponse($method.' '.$path, new GraphResponse( + success: $response->successful(), + data: [], + status: $response->status(), + errors: $response->json('error') ? [$response->json('error')] : [], + ), ['tenant' => $context['tenant'] ?? null]); + + return $response; + } + + private function toGraphResponse(string $action, Response $response, callable $transform): GraphResponse + { + if ($response->failed()) { + $error = $response->json('error') ?? $response->json() ?? $response->body(); + + return new GraphResponse( + success: false, + data: [], + status: $response->status(), + errors: is_array($error) ? [$error] : [$error], + ); + } + + $json = $response->json() ?? []; + + return new GraphResponse( + success: true, + data: $transform(is_array($json) ? $json : []), + status: $response->status(), + ); + } + + /** + * @return array{tenant:string,client_id:string,client_secret:string|null,scope:array,token_url:string} + */ + private function resolveContext(array $options): array + { + $tenant = $options['tenant'] ?? $this->tenantId; + $clientId = $options['client_id'] ?? $this->clientId; + $clientSecret = $options['client_secret'] ?? $this->clientSecret; + $tokenUrlTemplate = $options['token_url'] ?? $this->tokenUrlTemplate; + $scopes = $this->normalizeScopes($options['scope'] ?? null); + + return [ + 'tenant' => $tenant, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'scope' => $scopes, + 'token_url' => sprintf($tokenUrlTemplate, $tenant), + ]; + } + + /** + * @param array|string|null $scope + * @return array + */ + private function normalizeScopes(array|string|null $scope): array + { + if ($scope === null) { + return $this->defaultScopes; + } + + if (is_string($scope)) { + $scope = array_values(array_filter(explode(' ', $scope))); + } + + if (is_array($scope)) { + $scope = array_values(array_filter($scope, static fn ($value) => ! empty($value))); + } + + return $scope ?: $this->defaultScopes; + } + + private function endpointFor(string $policyType): string + { + $supported = config('tenantpilot.supported_policy_types', []); + foreach ($supported as $type) { + if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { + return $type['endpoint']; + } + } + + return 'deviceManagement/'.$policyType; + } + + private function getAccessToken(array $context): string + { + $tenant = $context['tenant'] ?? $this->tenantId; + $clientId = $context['client_id'] ?? $this->clientId; + $scopes = $context['scope'] ?? $this->defaultScopes; + + $cacheKey = sprintf('graph_token_%s_%s_%s', $tenant, $clientId, md5(implode('|', $scopes))); + + if ($token = Cache::get($cacheKey)) { + return $token; + } + + [$token, $ttl] = $this->requestAccessToken($context); + + Cache::put($cacheKey, $token, now()->addSeconds($ttl)); + + return $token; + } + + /** + * @return array{0:string,1:int} [token, ttlSeconds] + */ + private function requestAccessToken(array $context): array + { + $tenant = $context['tenant'] ?? $this->tenantId; + $clientId = $context['client_id'] ?? $this->clientId; + $clientSecret = $context['client_secret'] ?? $this->clientSecret; + $scopes = $context['scope'] ?? $this->defaultScopes; + $tokenUrl = $context['token_url'] ?? sprintf($this->tokenUrlTemplate, $tenant); + + $response = Http::asForm() + ->timeout($this->timeout) + ->retry($this->retryTimes, $this->retrySleepMs) + ->post($tokenUrl, [ + 'client_id' => $clientId, + 'scope' => implode(' ', $scopes), + 'client_secret' => $clientSecret, + 'grant_type' => 'client_credentials', + ]); + + if ($response->failed()) { + $error = $response->json('error') ?? $response->json() ?? $response->body(); + + throw new GraphException( + 'Unable to fetch Graph token', + $response->status(), + ['error' => $error] + ); + } + + $data = $response->json(); + $expiresIn = (int) ($data['expires_in'] ?? 300); + $ttl = max(60, $expiresIn - 60); + + return [(string) $data['access_token'], $ttl]; + } +} diff --git a/app/Services/Graph/NullGraphClient.php b/app/Services/Graph/NullGraphClient.php new file mode 100644 index 0000000..ad40ce5 --- /dev/null +++ b/app/Services/Graph/NullGraphClient.php @@ -0,0 +1,49 @@ + [ + 'id' => $policyId, + 'type' => $policyType, + 'source' => 'null-graph', + ], + 'warnings' => ['Graph client not configured; using stub payload'], + ], + warnings: ['Graph client not configured; using stub payload'], + ); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse( + success: true, + data: ['id' => $options['tenant'] ?? 'unset'], + warnings: ['Graph client not configured; returning stub organization'] + ); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse( + success: true, + data: ['status' => 'skipped', 'source' => 'null-graph'], + warnings: ['Graph client not configured; apply operation skipped'] + ); + } +} diff --git a/app/Services/Intune/AuditLogger.php b/app/Services/Intune/AuditLogger.php new file mode 100644 index 0000000..9e2e7c7 --- /dev/null +++ b/app/Services/Intune/AuditLogger.php @@ -0,0 +1,38 @@ + $tenant->id, + 'actor_id' => $actorId, + 'actor_email' => $actorEmail, + 'actor_name' => $actorName, + 'action' => $action, + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + 'status' => $status, + 'metadata' => $metadata + $context, + 'recorded_at' => CarbonImmutable::now(), + ]); + } +} diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php new file mode 100644 index 0000000..e8a1821 --- /dev/null +++ b/app/Services/Intune/BackupService.php @@ -0,0 +1,282 @@ + $policyIds + */ + public function createBackupSet( + Tenant $tenant, + array $policyIds, + ?string $actorEmail = null, + ?string $actorName = null, + ?string $name = null, + ): BackupSet { + $this->assertActiveTenant($tenant); + + $policies = Policy::query() + ->where('tenant_id', $tenant->id) + ->whereIn('id', $policyIds) + ->get(); + + $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name) { + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => $name ?? CarbonImmutable::now()->format('Y-m-d H:i:s').' backup', + 'created_by' => $actorEmail, + 'status' => 'running', + 'metadata' => [], + ]); + + $failures = []; + $itemsCreated = 0; + + foreach ($policies as $policy) { + [$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); + + if ($failure !== null) { + $failures[] = $failure; + + continue; + } + + if ($item !== null) { + $itemsCreated++; + } + } + + $status = $this->resolveStatus($itemsCreated, $failures); + + $backupSet->update([ + 'status' => $status, + 'item_count' => $itemsCreated, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => ['failures' => $failures], + ]); + + return $backupSet->refresh(); + }); + + $this->auditLogger->log( + tenant: $tenant, + action: 'backup.created', + context: [ + 'metadata' => [ + 'backup_set_id' => $backupSet->id, + 'item_count' => $backupSet->item_count, + 'status' => $backupSet->status, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'backup_set', + resourceId: (string) $backupSet->id, + status: $backupSet->status === 'completed' ? 'success' : 'partial' + ); + + return $backupSet; + } + + /** + * Add snapshots for additional policies to an existing backup set. + * + * @param array $policyIds + */ + public function addPoliciesToSet( + Tenant $tenant, + BackupSet $backupSet, + array $policyIds, + ?string $actorEmail = null, + ?string $actorName = null, + ): BackupSet { + $this->assertActiveTenant($tenant); + + if ($backupSet->trashed() || $backupSet->tenant_id !== $tenant->id) { + throw new \RuntimeException('Backup set is archived or does not belong to the current tenant.'); + } + + $existingPolicyIds = $backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all(); + $policyIds = array_values(array_diff($policyIds, $existingPolicyIds)); + + if (empty($policyIds)) { + return $backupSet->refresh(); + } + + $policies = Policy::query() + ->where('tenant_id', $tenant->id) + ->whereIn('id', $policyIds) + ->get(); + + $metadata = $backupSet->metadata ?? []; + $failures = $metadata['failures'] ?? []; + $itemsCreated = 0; + + foreach ($policies as $policy) { + [$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); + + if ($failure !== null) { + $failures[] = $failure; + + continue; + } + + if ($item !== null) { + $itemsCreated++; + } + } + + $status = $this->resolveStatus($itemsCreated, $failures); + + $backupSet->update([ + 'status' => $status, + 'item_count' => $backupSet->items()->count(), + 'completed_at' => CarbonImmutable::now(), + 'metadata' => ['failures' => $failures], + ]); + + $this->auditLogger->log( + tenant: $tenant, + action: 'backup.items_added', + context: [ + 'metadata' => [ + 'backup_set_id' => $backupSet->id, + 'added_count' => $itemsCreated, + 'status' => $status, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'backup_set', + resourceId: (string) $backupSet->id, + status: $status === 'completed' ? 'success' : 'partial' + ); + + return $backupSet->refresh(); + } + + private function resolveStatus(int $itemsCreated, array $failures): string + { + return match (true) { + $itemsCreated === 0 && count($failures) > 0 => 'failed', + count($failures) > 0 => 'partial', + default => 'completed', + }; + } + + /** + * @return array{0:?BackupItem,1:?array{policy_id:int,reason:string,status:int|string|null}} + */ + private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $policy, ?string $actorEmail = null): array + { + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + $context = [ + 'tenant' => $tenantIdentifier, + 'policy_type' => $policy->policy_type, + 'policy_id' => $policy->external_id, + ]; + + $this->graphLogger->logRequest('get_policy', $context); + + try { + $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $policy->platform, + ]); + } catch (Throwable $throwable) { + $mapped = GraphErrorMapper::fromThrowable($throwable, $context); + + return [ + null, + [ + 'policy_id' => $policy->id, + 'reason' => $mapped->getMessage(), + 'status' => $mapped->status, + ], + ]; + } + + $this->graphLogger->logResponse('get_policy', $response, $context); + + $payload = $response->data['payload'] ?? $response->data; + $metadata = Arr::except($response->data, ['payload']); + + if ($response->failed()) { + $reason = $response->warnings[0] ?? 'Graph request failed'; + $failure = [ + 'policy_id' => $policy->id, + 'reason' => $reason, + 'status' => $response->status, + ]; + + if (! config('graph.stub_on_failure')) { + return [null, $failure]; + } + + // Fallback to a stub payload for local/dev when Graph fails. + $payload = [ + 'id' => $policy->external_id, + 'type' => $policy->policy_type, + 'source' => 'stub', + 'warning' => $reason, + ]; + $metadata['warnings'] = $response->warnings ?? [$reason]; + } + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $payload, + 'metadata' => $metadata, + ]); + + $this->versionService->captureVersion( + policy: $policy, + payload: $payload, + createdBy: $actorEmail, + metadata: [ + 'source' => 'backup', + 'backup_set_id' => $backupSet->id, + 'backup_item_id' => $backupItem->id, + ] + ); + + return [$backupItem, null]; + } + + private function assertActiveTenant(Tenant $tenant): void + { + if (! $tenant->isActive()) { + throw new \RuntimeException('Tenant is archived or inactive.'); + } + } +} diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php new file mode 100644 index 0000000..734839b --- /dev/null +++ b/app/Services/Intune/PolicySyncService.php @@ -0,0 +1,100 @@ + IDs of policies synced or created + */ + public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array + { + if (! $tenant->isActive()) { + throw new \RuntimeException('Tenant is archived or inactive.'); + } + + $types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []); + $synced = []; + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + foreach ($types as $typeConfig) { + $policyType = $typeConfig['type']; + $platform = $typeConfig['platform'] ?? null; + + $this->graphLogger->logRequest('list_policies', [ + 'tenant' => $tenantIdentifier, + 'policy_type' => $policyType, + 'platform' => $platform, + ]); + + try { + $response = $this->graphClient->listPolicies($policyType, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $platform, + ]); + } catch (Throwable $throwable) { + throw GraphErrorMapper::fromThrowable($throwable, [ + 'policy_type' => $policyType, + 'tenant_id' => $tenant->id, + 'tenant_identifier' => $tenantIdentifier, + ]); + } + + $this->graphLogger->logResponse('list_policies', $response, [ + 'policy_type' => $policyType, + 'tenant_id' => $tenant->id, + 'tenant' => $tenantIdentifier, + ]); + + if ($response->failed()) { + continue; + } + + foreach ($response->data as $policyData) { + $externalId = $policyData['id'] ?? $policyData['external_id'] ?? null; + + if ($externalId === null) { + continue; + } + + $displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy'; + $policyPlatform = $platform ?? ($policyData['platform'] ?? null); + + $policy = Policy::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'external_id' => $externalId, + 'policy_type' => $policyType, + ], + [ + 'display_name' => $displayName, + 'platform' => $policyPlatform, + 'last_synced_at' => now(), + 'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']), + ] + ); + + $synced[] = $policy->id; + } + } + + return $synced; + } +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php new file mode 100644 index 0000000..42088bf --- /dev/null +++ b/app/Services/Intune/RestoreService.php @@ -0,0 +1,271 @@ +|null $selectedItemIds + */ + public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array + { + $this->assertActiveContext($tenant, $backupSet); + + $items = $this->loadItems($backupSet, $selectedItemIds); + + return $items->map(function (BackupItem $item) use ($tenant) { + $existing = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', $item->policy_identifier) + ->where('policy_type', $item->policy_type) + ->first(); + + return [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'policy_type' => $item->policy_type, + 'platform' => $item->platform, + 'action' => $existing ? 'update' : 'create', + 'conflict' => false, + ]; + })->all(); + } + + /** + * Execute restore or dry-run for selected items. + * + * @param array|null $selectedItemIds + */ + public function execute( + Tenant $tenant, + BackupSet $backupSet, + ?array $selectedItemIds = null, + bool $dryRun = true, + ?string $actorEmail = null, + ?string $actorName = null, + ): RestoreRun { + $this->assertActiveContext($tenant, $backupSet); + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + $items = $this->loadItems($backupSet, $selectedItemIds); + $preview = $this->preview($tenant, $backupSet, $selectedItemIds); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'started_at' => CarbonImmutable::now(), + 'metadata' => [], + ]); + + $results = []; + $failures = 0; + + foreach ($items as $item) { + $context = [ + 'tenant' => $tenantIdentifier, + 'policy_type' => $item->policy_type, + 'policy_id' => $item->policy_identifier, + 'backup_item_id' => $item->id, + ]; + + if ($dryRun) { + $results[] = $context + ['status' => 'dry_run']; + + continue; + } + + $this->graphLogger->logRequest('apply_policy', $context); + + try { + $payload = $this->sanitizePayload($item->payload); + + $response = $this->graphClient->applyPolicy( + $item->policy_type, + $item->policy_identifier, + $payload, + [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $item->platform, + ] + ); + } catch (Throwable $throwable) { + $mapped = GraphErrorMapper::fromThrowable($throwable, $context); + $results[] = $context + [ + 'status' => 'failed', + 'reason' => $mapped->getMessage(), + 'code' => $mapped->status, + ]; + $failures++; + + continue; + } + + $this->graphLogger->logResponse('apply_policy', $response, $context); + + if ($response->failed()) { + $results[] = $context + [ + 'status' => 'failed', + 'reason' => 'Graph apply failed', + 'code' => $response->status, + ]; + $failures++; + + continue; + } + + $results[] = $context + ['status' => 'applied']; + + $policy = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', $item->policy_identifier) + ->where('policy_type', $item->policy_type) + ->first(); + + if ($policy) { + $this->versionService->captureVersion( + policy: $policy, + payload: $item->payload, + createdBy: $actorEmail, + metadata: [ + 'source' => 'restore', + 'restore_run_id' => $restoreRun->id, + 'backup_item_id' => $item->id, + ] + ); + } + } + + $status = $dryRun + ? 'previewed' + : (match (true) { + $failures === count($results) => 'failed', + $failures > 0 => 'partial', + default => 'completed', + }); + + $restoreRun->update([ + 'status' => $status, + 'results' => $results, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'failed' => $failures, + 'total' => count($results), + ], + ]); + + $this->auditLogger->log( + tenant: $tenant, + action: $dryRun ? 'restore.previewed' : 'restore.executed', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'status' => $status, + 'dry_run' => $dryRun, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: $status === 'completed' || $status === 'previewed' ? 'success' : 'partial' + ); + + return $restoreRun->refresh(); + } + + /** + * @param array|null $selectedItemIds + */ + private function loadItems(BackupSet $backupSet, ?array $selectedItemIds = null): Collection + { + $query = $backupSet->items()->getQuery(); + + if ($selectedItemIds !== null) { + $query->whereIn('id', $selectedItemIds); + } + + return $query->orderBy('id')->get(); + } + + /** + * Strip read-only/metadata fields before sending payload back to Graph. + */ + private function sanitizePayload(array $payload): array + { + $readOnlyKeys = [ + '@odata.context', + 'id', + 'createdDateTime', + 'lastModifiedDateTime', + 'version', + 'supportsScopeTags', + 'roleScopeTagIds', + ]; + + $clean = []; + + foreach ($payload as $key => $value) { + // Drop read-only/meta keys except @odata.type which we keep for type hinting + if (in_array($key, $readOnlyKeys, true)) { + continue; + } + + // Keep @odata.type for Graph type resolution + if ($key === '@odata.type') { + $clean[$key] = $value; + + continue; + } + + if (is_array($value)) { + $clean[$key] = $this->sanitizePayload($value); + + continue; + } + + $clean[$key] = $value; + } + + return $clean; + } + + private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void + { + if (! $tenant->isActive()) { + throw new \RuntimeException('Tenant is archived or inactive.'); + } + + if ($backupSet->trashed()) { + throw new \RuntimeException('Backup set is archived.'); + } + } +} diff --git a/app/Services/Intune/TenantConfigService.php b/app/Services/Intune/TenantConfigService.php new file mode 100644 index 0000000..aec333c --- /dev/null +++ b/app/Services/Intune/TenantConfigService.php @@ -0,0 +1,66 @@ +graphOptions($tenant); + + if ($options['tenant'] === null) { + return [ + 'success' => false, + 'error_message' => 'Tenant ID is missing', + 'requires_consent' => false, + ]; + } + + try { + $response = $this->graphClient->getOrganization($options); + } catch (Throwable $throwable) { + $mapped = GraphErrorMapper::fromThrowable($throwable, ['tenant' => $options['tenant']]); + + return [ + 'success' => false, + 'error_message' => $mapped->getMessage(), + 'requires_consent' => $this->requiresConsent($mapped->getMessage()), + ]; + } + + if ($response->failed()) { + $message = $response->errors[0]['message'] ?? $response->errors[0] ?? 'Graph connectivity failed'; + + return [ + 'success' => false, + 'error_message' => is_string($message) ? $message : json_encode($message), + 'requires_consent' => $this->requiresConsent((string) $message), + ]; + } + + return ['success' => true, 'error_message' => null, 'requires_consent' => false]; + } + + /** + * @return array{tenant:?string,client_id:?string,client_secret:?string} + */ + public function graphOptions(Tenant $tenant): array + { + return $tenant->graphOptions(); + } + + private function requiresConsent(string $message): bool + { + return str_contains(strtolower($message), 'consent'); + } +} diff --git a/app/Services/Intune/TenantPermissionService.php b/app/Services/Intune/TenantPermissionService.php new file mode 100644 index 0000000..fcd31d3 --- /dev/null +++ b/app/Services/Intune/TenantPermissionService.php @@ -0,0 +1,116 @@ +}> + */ + public function getRequiredPermissions(): array + { + return config('intune_permissions.permissions', []); + } + + /** + * @return array|null,last_checked_at:?\Illuminate\Support\Carbon}> + */ + public function getGrantedPermissions(Tenant $tenant): array + { + return TenantPermission::query() + ->where('tenant_id', $tenant->id) + ->get() + ->keyBy('permission_key') + ->map(fn (TenantPermission $permission) => [ + 'status' => $permission->status, + 'details' => $permission->details, + 'last_checked_at' => $permission->last_checked_at, + ]) + ->all(); + } + + /** + * @param array|null}|string>|null $grantedStatuses + * @param bool $persist Persist comparison results to tenant_permissions + * @return array{overall_status:string,permissions:array,status:string,details:array|null}>} + */ + public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $persist = true): array + { + $required = $this->getRequiredPermissions(); + $granted = $this->normalizeGrantedStatuses($grantedStatuses ?? $this->getGrantedPermissions($tenant)); + $results = []; + $hasMissing = false; + $hasErrors = false; + $checkedAt = now(); + + foreach ($required as $permission) { + $key = $permission['key']; + $status = $granted[$key]['status'] ?? 'missing'; + $details = $granted[$key]['details'] ?? null; + + if ($persist) { + TenantPermission::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'permission_key' => $key, + ], + [ + 'status' => $status, + 'details' => $details, + 'last_checked_at' => $checkedAt, + ] + ); + } + + $results[] = [ + 'key' => $key, + 'type' => $permission['type'] ?? 'application', + 'description' => $permission['description'] ?? null, + 'features' => $permission['features'] ?? [], + 'status' => $status, + 'details' => $details, + ]; + + $hasMissing = $hasMissing || $status === 'missing'; + $hasErrors = $hasErrors || $status === 'error'; + } + + $overall = match (true) { + $hasErrors => 'error', + $hasMissing => 'missing', + default => 'ok', + }; + + return [ + 'overall_status' => $overall, + 'permissions' => $results, + ]; + } + + /** + * @param array|null}|string> $granted + * @return array|null}> + */ + private function normalizeGrantedStatuses(array $granted): array + { + $normalized = []; + + foreach ($granted as $key => $value) { + if (is_string($value)) { + $normalized[$key] = ['status' => $value, 'details' => null]; + + continue; + } + + $normalized[$key] = [ + 'status' => $value['status'] ?? 'missing', + 'details' => $value['details'] ?? null, + ]; + } + + return $normalized; + } +} diff --git a/app/Services/Intune/VersionDiff.php b/app/Services/Intune/VersionDiff.php new file mode 100644 index 0000000..404019b --- /dev/null +++ b/app/Services/Intune/VersionDiff.php @@ -0,0 +1,58 @@ + $value) { + if (! array_key_exists($key, $fromFlat)) { + $added[$key] = $value; + + continue; + } + + if ($fromFlat[$key] !== $value) { + $changed[$key] = [ + 'from' => $fromFlat[$key], + 'to' => $value, + ]; + } + } + + foreach ($fromFlat as $key => $value) { + if (! array_key_exists($key, $toFlat)) { + $removed[$key] = $value; + } + } + + $summary = [ + 'added' => count($added), + 'removed' => count($removed), + 'changed' => count($changed), + 'message' => sprintf( + '%d added, %d removed, %d changed', + count($added), + count($removed), + count($changed) + ), + ]; + + return [ + 'summary' => $summary, + 'added' => $added, + 'removed' => $removed, + 'changed' => $changed, + ]; + } +} diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php new file mode 100644 index 0000000..ea57459 --- /dev/null +++ b/app/Services/Intune/VersionService.php @@ -0,0 +1,58 @@ +nextVersionNumber($policy); + + $version = PolicyVersion::create([ + 'tenant_id' => $policy->tenant_id, + 'policy_id' => $policy->id, + 'version_number' => $versionNumber, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => $createdBy, + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => $payload, + 'metadata' => $metadata, + ]); + + $this->auditLogger->log( + tenant: $policy->tenant, + action: 'policy.versioned', + context: [ + 'metadata' => [ + 'policy_id' => $policy->id, + 'version_number' => $versionNumber, + ], + ], + actorEmail: $createdBy, + resourceType: 'policy', + resourceId: (string) $policy->id + ); + + return $version; + } + + private function nextVersionNumber(Policy $policy): int + { + $current = PolicyVersion::query() + ->where('policy_id', $policy->id) + ->max('version_number'); + + return (int) ($current ?? 0) + 1; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..e337384 --- /dev/null +++ b/boost.json @@ -0,0 +1,7 @@ +{ + "agents": [ + "codex", + "opencode" + ], + "guidelines": [] +} diff --git a/composer.json b/composer.json index ec032f2..3db5e8f 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "laravel/tinker": "^2.10.1" }, "require-dev": { + "barryvdh/laravel-debugbar": "^3.16", "fakerphp/faker": "^1.23", "laravel/boost": "^1.8", "laravel/pail": "^1.2.2", diff --git a/composer.lock b/composer.lock index c7a832f..bb92e51 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dc584e78590e5809608b958300752c90", + "content-hash": "4ee38f2ac2d8cdc0f333cc36bbeb7eaa", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -8183,6 +8183,91 @@ } ], "packages-dev": [ + { + "name": "barryvdh/laravel-debugbar", + "version": "v3.16.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-debugbar.git", + "reference": "21b2c6fce05453efd4bceb34f9fddaa1cdb44090" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/21b2c6fce05453efd4bceb34f9fddaa1cdb44090", + "reference": "21b2c6fce05453efd4bceb34f9fddaa1cdb44090", + "shasum": "" + }, + "require": { + "illuminate/routing": "^10|^11|^12", + "illuminate/session": "^10|^11|^12", + "illuminate/support": "^10|^11|^12", + "php": "^8.1", + "php-debugbar/php-debugbar": "^2.2.4", + "symfony/finder": "^6|^7" + }, + "require-dev": { + "mockery/mockery": "^1.3.3", + "orchestra/testbench-dusk": "^7|^8|^9|^10", + "phpunit/phpunit": "^9.5.10|^10|^11", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Debugbar": "Barryvdh\\Debugbar\\Facades\\Debugbar" + }, + "providers": [ + "Barryvdh\\Debugbar\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.16-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Barryvdh\\Debugbar\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "PHP Debugbar integration for Laravel", + "keywords": [ + "debug", + "debugbar", + "dev", + "laravel", + "profiler", + "webprofiler" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-debugbar/issues", + "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.16.1" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-11-19T08:31:25+00:00" + }, { "name": "brianium/paratest", "version": "v7.15.0", @@ -9861,6 +9946,79 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-debugbar/php-debugbar", + "version": "v2.2.4", + "source": { + "type": "git", + "url": "https://github.com/php-debugbar/php-debugbar.git", + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "shasum": "" + }, + "require": { + "php": "^8", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^4|^5|^6|^7" + }, + "replace": { + "maximebf/debugbar": "self.version" + }, + "require-dev": { + "dbrekelmans/bdi": "^1", + "phpunit/phpunit": "^8|^9", + "symfony/panther": "^1|^2.1", + "twig/twig": "^1.38|^2.7|^3.0" + }, + "suggest": { + "kriswallsmith/assetic": "The best way to manage assets", + "monolog/monolog": "Log using Monolog", + "predis/predis": "Redis storage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "psr-4": { + "DebugBar\\": "src/DebugBar/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maxime Bouroumeau-Fuseau", + "email": "maxime.bouroumeau@gmail.com", + "homepage": "http://maximebf.com" + }, + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "Debug bar in the browser for php application", + "homepage": "https://github.com/php-debugbar/php-debugbar", + "keywords": [ + "debug", + "debug bar", + "debugbar", + "dev" + ], + "support": { + "issues": "https://github.com/php-debugbar/php-debugbar/issues", + "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.4" + }, + "time": "2025-07-22T14:01:30+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -11724,5 +11882,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/graph.php b/config/graph.php new file mode 100644 index 0000000..9028b5b --- /dev/null +++ b/config/graph.php @@ -0,0 +1,26 @@ + (bool) (env('GRAPH_CLIENT_ID') && env('GRAPH_CLIENT_SECRET') && env('GRAPH_TENANT_ID')), + + 'tenant_id' => env('GRAPH_TENANT_ID', ''), + 'client_id' => env('GRAPH_CLIENT_ID', ''), + 'client_secret' => env('GRAPH_CLIENT_SECRET', ''), + 'scope' => env('GRAPH_SCOPE') ?: 'https://graph.microsoft.com/.default', + + 'base_url' => env('GRAPH_BASE_URL', 'https://graph.microsoft.com'), + 'version' => env('GRAPH_VERSION', 'beta'), + + 'token_url' => env('GRAPH_TOKEN_URL', 'https://login.microsoftonline.com/%s/oauth2/v2.0/token'), + + 'timeout' => (int) env('GRAPH_TIMEOUT', 10), + + 'retry' => [ + 'times' => (int) env('GRAPH_RETRY_TIMES', 2), + 'sleep' => (int) env('GRAPH_RETRY_SLEEP', 200), // milliseconds + ], + + // When true (default in local/debug), BackupService will fall back to stub payloads + // instead of failing the backup entirely if Graph returns an error. + 'stub_on_failure' => (bool) env('GRAPH_STUB_ON_FAILURE', env('APP_ENV') === 'local' || env('APP_DEBUG')), +]; diff --git a/config/intune_permissions.php b/config/intune_permissions.php new file mode 100644 index 0000000..1565a23 --- /dev/null +++ b/config/intune_permissions.php @@ -0,0 +1,24 @@ + [ + [ + 'key' => 'DeviceManagementConfiguration.ReadWrite.All', + 'type' => 'application', + 'description' => 'Read and write Intune device configuration policies.', + 'features' => ['policy-sync', 'backup', 'restore'], + ], + [ + 'key' => 'DeviceManagementApps.ReadWrite.All', + 'type' => 'application', + 'description' => 'Manage app configuration and assignments for Intune.', + 'features' => ['backup', 'restore'], + ], + [ + 'key' => 'Directory.Read.All', + 'type' => 'application', + 'description' => 'Read directory data needed for tenant health checks.', + 'features' => ['tenant-health'], + ], + ], +]; diff --git a/config/tenantpilot.php b/config/tenantpilot.php new file mode 100644 index 0000000..68180ea --- /dev/null +++ b/config/tenantpilot.php @@ -0,0 +1,107 @@ + [ + [ + 'type' => 'deviceConfiguration', + 'label' => 'Device Configuration', + 'category' => 'Configuration', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceConfigurations', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], + [ + 'type' => 'deviceCompliancePolicy', + 'label' => 'Device Compliance', + 'category' => 'Compliance', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceCompliancePolicies', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], + [ + 'type' => 'appProtectionPolicy', + 'label' => 'App Protection (MAM)', + 'category' => 'Apps/MAM', + 'platform' => 'mobile', + 'endpoint' => 'deviceAppManagement/managedAppPolicies', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], + [ + 'type' => 'conditionalAccessPolicy', + 'label' => 'Conditional Access', + 'category' => 'Conditional Access', + 'platform' => 'all', + 'endpoint' => 'identity/conditionalAccess/policies', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'deviceManagementScript', + 'label' => 'PowerShell Scripts', + 'category' => 'Scripts', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/deviceManagementScripts', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], + [ + 'type' => 'enrollmentRestriction', + 'label' => 'Enrollment Restrictions', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'windowsAutopilotDeploymentProfile', + 'label' => 'Windows Autopilot Profiles', + 'category' => 'Autopilot', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsAutopilotDeploymentProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], + [ + 'type' => 'windowsEnrollmentStatusPage', + 'label' => 'Enrollment Status Page (ESP)', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'filter' => "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], + [ + 'type' => 'endpointSecurityIntent', + 'label' => 'Endpoint Security Intents', + 'category' => 'Endpoint Security', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/intents', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], + [ + 'type' => 'mobileApp', + 'label' => 'Applications (Metadata only)', + 'category' => 'Applications', + 'platform' => 'all', + 'endpoint' => 'deviceAppManagement/mobileApps', + 'backup' => 'metadata-only', + 'restore' => 'enabled', + 'risk' => 'low-medium', + ], + ], +]; diff --git a/database/migrations/2025_12_10_000100_create_tenants_table.php b/database/migrations/2025_12_10_000100_create_tenants_table.php new file mode 100644 index 0000000..0e4feb7 --- /dev/null +++ b/database/migrations/2025_12_10_000100_create_tenants_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name'); + $table->string('external_id')->unique(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenants'); + } +}; diff --git a/database/migrations/2025_12_10_000110_create_policies_table.php b/database/migrations/2025_12_10_000110_create_policies_table.php new file mode 100644 index 0000000..fdbc9a7 --- /dev/null +++ b/database/migrations/2025_12_10_000110_create_policies_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('external_id'); + $table->string('policy_type'); + $table->string('platform')->nullable(); + $table->string('display_name'); + $table->timestamp('last_synced_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'external_id', 'policy_type']); + $table->index(['tenant_id', 'policy_type', 'platform']); + $table->index('last_synced_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('policies'); + } +}; diff --git a/database/migrations/2025_12_10_000120_create_policy_versions_table.php b/database/migrations/2025_12_10_000120_create_policy_versions_table.php new file mode 100644 index 0000000..ff9ec1f --- /dev/null +++ b/database/migrations/2025_12_10_000120_create_policy_versions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('policy_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('version_number'); + $table->string('policy_type'); + $table->string('platform')->nullable(); + $table->string('created_by')->nullable(); + $table->timestamp('captured_at')->useCurrent(); + $table->json('snapshot'); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['policy_id', 'version_number']); + $table->index(['tenant_id', 'policy_type']); + $table->index('captured_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('policy_versions'); + } +}; diff --git a/database/migrations/2025_12_10_000130_create_backup_sets_table.php b/database/migrations/2025_12_10_000130_create_backup_sets_table.php new file mode 100644 index 0000000..698d1ed --- /dev/null +++ b/database/migrations/2025_12_10_000130_create_backup_sets_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('created_by')->nullable(); + $table->string('status')->default('pending'); + $table->unsignedInteger('item_count')->default(0); + $table->timestamp('completed_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'status']); + $table->index('completed_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('backup_sets'); + } +}; diff --git a/database/migrations/2025_12_10_000140_create_backup_items_table.php b/database/migrations/2025_12_10_000140_create_backup_items_table.php new file mode 100644 index 0000000..258698a --- /dev/null +++ b/database/migrations/2025_12_10_000140_create_backup_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('backup_set_id')->constrained()->cascadeOnDelete(); + $table->foreignId('policy_id')->nullable()->constrained()->nullOnDelete(); + $table->string('policy_identifier'); + $table->string('policy_type'); + $table->string('platform')->nullable(); + $table->timestamp('captured_at')->useCurrent(); + $table->json('payload'); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['backup_set_id', 'policy_identifier', 'policy_type']); + $table->index(['tenant_id', 'policy_type']); + $table->index('captured_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('backup_items'); + } +}; diff --git a/database/migrations/2025_12_10_000150_create_restore_runs_table.php b/database/migrations/2025_12_10_000150_create_restore_runs_table.php new file mode 100644 index 0000000..eb9f394 --- /dev/null +++ b/database/migrations/2025_12_10_000150_create_restore_runs_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('backup_set_id')->constrained()->cascadeOnDelete(); + $table->string('requested_by')->nullable(); + $table->boolean('is_dry_run')->default(true); + $table->string('status')->default('pending'); + $table->json('requested_items')->nullable(); + $table->json('preview')->nullable(); + $table->json('results')->nullable(); + $table->text('failure_reason')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'status']); + $table->index('started_at'); + $table->index('completed_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('restore_runs'); + } +}; diff --git a/database/migrations/2025_12_10_000160_create_audit_logs_table.php b/database/migrations/2025_12_10_000160_create_audit_logs_table.php new file mode 100644 index 0000000..23f8a29 --- /dev/null +++ b/database/migrations/2025_12_10_000160_create_audit_logs_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_email')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('action'); + $table->string('resource_type')->nullable(); + $table->string('resource_id')->nullable(); + $table->string('status')->default('success'); + $table->json('metadata')->nullable(); + $table->timestamp('recorded_at')->useCurrent(); + $table->timestamps(); + + $table->index(['tenant_id', 'action']); + $table->index(['tenant_id', 'resource_type']); + $table->index('recorded_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/database/migrations/2025_12_11_121623_add_app_fields_to_tenants_table.php b/database/migrations/2025_12_11_121623_add_app_fields_to_tenants_table.php new file mode 100644 index 0000000..a718539 --- /dev/null +++ b/database/migrations/2025_12_11_121623_add_app_fields_to_tenants_table.php @@ -0,0 +1,55 @@ +string('tenant_id')->nullable()->after('name'); + $table->string('domain')->nullable()->after('tenant_id'); + $table->string('app_client_id')->nullable()->after('domain'); + $table->text('app_client_secret')->nullable()->after('app_client_id'); + $table->string('app_certificate_thumbprint')->nullable()->after('app_client_secret'); + $table->string('app_status')->default('unknown')->after('app_certificate_thumbprint'); + $table->text('app_notes')->nullable()->after('app_status'); + + $table->unique('tenant_id'); + }); + + Schema::table('tenants', function (Blueprint $table) { + $table->index('domain'); + $table->index('app_status'); + }); + + DB::table('tenants') + ->whereNull('tenant_id') + ->update(['tenant_id' => DB::raw('external_id')]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropUnique(['tenant_id']); + $table->dropColumn([ + 'tenant_id', + 'domain', + 'app_client_id', + 'app_client_secret', + 'app_certificate_thumbprint', + 'app_status', + 'app_notes', + ]); + }); + } +}; diff --git a/database/migrations/2025_12_11_122423_create_tenant_permissions_table.php b/database/migrations/2025_12_11_122423_create_tenant_permissions_table.php new file mode 100644 index 0000000..a1b47a9 --- /dev/null +++ b/database/migrations/2025_12_11_122423_create_tenant_permissions_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('permission_key'); + $table->string('status')->default('missing'); + $table->timestamp('last_checked_at')->nullable(); + $table->json('details')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'permission_key']); + $table->index(['tenant_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_permissions'); + } +}; diff --git a/database/migrations/2025_12_11_130000_add_soft_deletes_and_status_housekeeping.php b/database/migrations/2025_12_11_130000_add_soft_deletes_and_status_housekeeping.php new file mode 100644 index 0000000..45c4361 --- /dev/null +++ b/database/migrations/2025_12_11_130000_add_soft_deletes_and_status_housekeeping.php @@ -0,0 +1,58 @@ +softDeletes(); + }); + + Schema::table('backup_items', function (Blueprint $table) { + $table->softDeletes(); + }); + + Schema::table('policy_versions', function (Blueprint $table) { + $table->softDeletes(); + }); + + Schema::table('restore_runs', function (Blueprint $table) { + $table->softDeletes(); + }); + + Schema::table('tenants', function (Blueprint $table) { + $table->string('status')->default('active')->after('app_status'); + $table->softDeletes(); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::table('backup_sets', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + + Schema::table('backup_items', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + + Schema::table('policy_versions', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + + Schema::table('restore_runs', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('status'); + $table->dropSoftDeletes(); + $table->dropIndex(['status']); + }); + } +}; diff --git a/database/migrations/2025_12_11_192942_add_is_current_to_tenants.php b/database/migrations/2025_12_11_192942_add_is_current_to_tenants.php new file mode 100644 index 0000000..6e16d4b --- /dev/null +++ b/database/migrations/2025_12_11_192942_add_is_current_to_tenants.php @@ -0,0 +1,69 @@ +boolean('is_current')->default(false)->after('status'); + }); + + DB::table('tenants')->update(['is_current' => false]); + + $preferredCurrentId = DB::table('tenants') + ->whereNull('deleted_at') + ->where('status', 'active') + ->where('app_status', 'ok') + ->orderBy('created_at') + ->value('id'); + + if ($preferredCurrentId === null) { + $preferredCurrentId = DB::table('tenants') + ->whereNull('deleted_at') + ->where('status', 'active') + ->where('tenant_id', '<>', 'local-tenant') + ->orderBy('created_at') + ->value('id'); + } + + if ($preferredCurrentId === null) { + $preferredCurrentId = DB::table('tenants') + ->whereNull('deleted_at') + ->where('status', 'active') + ->orderBy('created_at') + ->value('id'); + } + + if ($preferredCurrentId !== null) { + DB::table('tenants') + ->where('id', $preferredCurrentId) + ->update(['is_current' => true]); + } + + DB::table('tenants') + ->where('tenant_id', 'local-tenant') + ->update(['status' => 'archived', 'is_current' => false]); + + DB::statement('CREATE UNIQUE INDEX tenants_current_unique ON tenants (is_current) WHERE is_current = true AND deleted_at IS NULL'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS tenants_current_unique'); + + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('is_current'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..d4a562d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,7 +15,9 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); + $this->call([ + PoliciesSeeder::class, + ]); User::factory()->create([ 'name' => 'Test User', diff --git a/database/seeders/PoliciesSeeder.php b/database/seeders/PoliciesSeeder.php new file mode 100644 index 0000000..8bf4c03 --- /dev/null +++ b/database/seeders/PoliciesSeeder.php @@ -0,0 +1,44 @@ + env('INTUNE_TENANT_ID', 'local-tenant'), + ], [ + 'name' => 'Default Tenant', + 'domain' => null, + 'metadata' => [], + ]); + + $supported = config('tenantpilot.supported_policy_types', []); + $now = now(); + + foreach ($supported as $index => $type) { + $policyType = $type['type']; + $platform = $type['platform'] ?? null; + $externalId = 'seed-'.$policyType.'-'.($platform ?? 'any').'-'.$index; + + Policy::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'external_id' => $externalId, + 'policy_type' => $policyType, + ], + [ + 'display_name' => $type['label'] ?? ucfirst($policyType), + 'platform' => $platform, + 'last_synced_at' => $now, + 'metadata' => ['seeded' => true], + ] + ); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..996075e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +services: + laravel.test: + build: + context: ./vendor/laravel/sail/runtimes/8.4 + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP:-1000}' + NODE_VERSION: '20' + image: tenantatlas-laravel + extra_hosts: + - 'host.docker.internal:host-gateway' + ports: + - '${APP_PORT:-80}:80' + - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' + environment: + WWWUSER: '${WWWUSER:-1000}' + LARAVEL_SAIL: 1 + APP_SERVICE: laravel.test + volumes: + - '.:/var/www/html' + networks: + - sail + depends_on: + - pgsql + - redis + + pgsql: + image: 'postgres:16' + ports: + - '${FORWARD_DB_PORT:-5432}:5432' + environment: + POSTGRES_DB: '${DB_DATABASE:-tenantatlas}' + POSTGRES_USER: '${DB_USERNAME:-root}' + POSTGRES_PASSWORD: '${DB_PASSWORD:-postgres}' + volumes: + - 'sail-pgsql:/var/lib/postgresql/data' + networks: + - sail + + redis: + image: 'redis:7-alpine' + ports: + - '${FORWARD_REDIS_PORT:-6379}:6379' + volumes: + - 'sail-redis:/data' + networks: + - sail + +volumes: + sail-pgsql: + driver: local + sail-redis: + driver: local + +networks: + sail: + driver: bridge diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..d8ed4ff --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './drizzle/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL ?? '', + }, + verbose: true, + strict: true, +}); diff --git a/drizzle/schema.ts b/drizzle/schema.ts new file mode 100644 index 0000000..4eb2e9f --- /dev/null +++ b/drizzle/schema.ts @@ -0,0 +1 @@ +// Drizzle schema placeholder. Run `drizzle-kit introspect` to generate tables from the live database. diff --git a/package-lock.json b/package-lock.json index 4dd5c24..fcd2ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,457 @@ "@tailwindcss/vite": "^4.0.0", "axios": "^1.11.0", "concurrently": "^9.0.1", + "drizzle-kit": "^0.31.8", + "drizzle-orm": "^0.45.1", "laravel-vite-plugin": "^2.0.0", + "pg": "^8.16.3", "tailwindcss": "^4.0.0", "vite": "^7.0.7" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1137,6 +1583,13 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1254,6 +1707,24 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1274,6 +1745,148 @@ "node": ">=8" } }, + "node_modules/drizzle-kit": { + "version": "0.31.8", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz", + "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1366,6 +1979,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1401,6 +2015,19 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1541,6 +2168,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1957,6 +2597,13 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1976,6 +2623,104 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2026,6 +2771,49 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -2043,6 +2831,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -2108,6 +2906,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2118,6 +2926,27 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2335,6 +3164,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 7686b29..b8b6f1d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "@tailwindcss/vite": "^4.0.0", "axios": "^1.11.0", "concurrently": "^9.0.1", + "drizzle-kit": "^0.31.8", + "drizzle-orm": "^0.45.1", "laravel-vite-plugin": "^2.0.0", + "pg": "^8.16.3", "tailwindcss": "^4.0.0", "vite": "^7.0.7" } diff --git a/resources/views/admin-consent-callback.blade.php b/resources/views/admin-consent-callback.blade.php new file mode 100644 index 0000000..9e7cb3f --- /dev/null +++ b/resources/views/admin-consent-callback.blade.php @@ -0,0 +1,37 @@ + + + + + + TenantPilot Admin Consent + + + +
+

Admin Consent Status

+

Tenant: {{ $tenant->name }} ({{ $tenant->graphTenantId() }})

+

+ + Status: {{ ucfirst(str_replace('_', ' ', $status)) }} + +

+ @if($error) +

Error: {{ $error }}

+ @elseif($consentGranted === false) +

Admin consent wurde abgelehnt.

+ @else +

Admin consent wurde bestätigt.

+ @endif + +

Zurück zur Tenant-Detailseite

+
+ + diff --git a/routes/web.php b/routes/web.php index 86a06c5..cf49536 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,15 @@ name('admin.consent.callback'); + +Route::get('/admin/consent/start', TenantOnboardingController::class) + ->name('admin.consent.start'); diff --git a/spechistory/spec.md b/spechistory/spec.md new file mode 100644 index 0000000..1ac2a6b --- /dev/null +++ b/spechistory/spec.md @@ -0,0 +1,259 @@ +# Feature Specification: TenantPilot v1 + +**Feature Branch**: `tenantpilot-v1` +**Created**: 2025-12-10 +**Status**: Draft +**Input**: TenantPilot v1 scope covering policy inventory, backup, version history, and defensive restore for Intune administrators. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Policy inventory listing (Priority: P1) + +Admin can view supported Intune policy types with normalized metadata for selection. + +**Why this priority**: Inventory is the entry point for backup/version flows. Without it, no downstream workflows are usable. + +**Independent Test**: From Filament, navigate to Policies; verify supported types render with identifiers, platform/type metadata, and tenant scoping. + +**Acceptance Scenarios**: + +1. **Given** an authenticated admin, **When** they open the Policies list, **Then** they see supported policy types with identifiers, platform, and last-updated metadata. +2. **Given** policy filtering by type, **When** the admin selects a type, **Then** only matching policies appear and the view remains tenant-scoped. + +--- + +### User Story 2 - Backup creation and browsing (Priority: P1) + +Admin creates backup sets containing multiple policies with immutable snapshots and can browse backup details in Filament. + +**Why this priority**: Backups provide safety and enable restore; immutability and audit are foundational. + +**Independent Test**: Initiate a backup set selecting multiple policies; confirm immutable JSONB snapshots persisted, audit log written, and Filament shows backup detail and items. + + +**Acceptance Scenarios**: + +1. **Given** selected policies, **When** the admin creates a backup set, **Then** backup items store immutable payload snapshots with policy identifiers and types. +2. **Given** a completed backup set, **When** the admin opens its detail page, **Then** all items and metadata display along with the audit record of creation. + +3. **Given** mehrere Backup-Sets existieren, + **When** der Admin ein Backup-Set auswählen oder ansehen möchte, + **Then** sieht er für jedes Set: + - einen sprechenden Namen (nicht nur Timestamp), + - das Erstellungsdatum, + - die Anzahl der enthaltenen Items, + - und optional eine kurze Beschreibung, damit er das Set sinnvoll unterscheiden kann. + +--- + +### User Story 3 - Version history and diff (Priority: P1) + +Admin can capture policy versions, view timelines, and compare any two versions with meaningful diffs. + +**Why this priority**: Version visibility and diffs enable rollback readiness and change comprehension. + +**Independent Test**: Create multiple versions for a policy; verify timeline ordering, version metadata, and diff output (human summary + JSON diff where feasible) between any two versions. + +**Acceptance Scenarios**: + +1. **Given** an admin triggers version capture, **When** the version is saved, **Then** an immutable snapshot and metadata (actor, time, type, tenant) are recorded. +2. **Given** two versions of the same policy, **When** the admin requests a comparison, **Then** the UI shows a human-readable summary and structured JSON diff where available. + +--- + +### User Story 4 - Restore with preview and confirmation (Priority: P1) + +Admin can run a restore from a backup set with preview/dry-run, selective restore, clear warnings, and required confirmation before execution. + +**Why this priority**: Restore is high-risk; safety features are mandatory for production readiness. + +**Independent Test**: Start a restore from a backup set in preview; view change summary and warnings; select items; confirm execution; verify audit logs and outcomes recorded (success/failure/partial). + +**Acceptance Scenarios**: + +1. **Given** a backup set, **When** the admin initiates a restore in preview mode, **Then** the system shows a change summary with selectable items and conflict warnings. +2. **Given** selected items and explicit confirmation, **When** execution proceeds, **Then** applied changes are tenant-scoped and audit logs record start, result, and any failures. + +3. **Given** mehrere Backup-Sets existieren, + **When** der Admin einen Restore Run erstellt, + **Then** zeigt die Auswahl für das "Backup set" mindestens: + - den Backup-Namen, + - das Erstellungsdatum, + - die Anzahl der Items, + damit der Admin das richtige Backup-Set sicher auswählen kann. + +4. **Given** ein Restore Run wurde erstellt, + **When** der Admin die Detailseite des Restore Runs öffnet, + **Then** sieht er, welche Policies/Items in diesem Run enthalten sind + (z. B. Liste der Policies mit Name/Typ/Plattform). +--- + +### User Story 5 - Operational readiness and environments (Priority: P2) + +Local development uses Sail; deployments target Dokploy staging then production with clear validation steps. + +**Why this priority**: Ensures reproducible local setup and safe promotion to production. + +**Independent Test**: Run the app locally via Sail; validate migrations on staging before production; confirm required env vars and queues/workers are documented. + + + + +### User Story 6 - Berechtigungsübersicht & Health-Status (Priority: P1) + +Als Admin möchte ich für jeden Tenant sehen, welche Microsoft Graph-Berechtigungen +erforderlich sind, welche bereits erteilt wurden und welche fehlen, damit ich +sicherstellen kann, dass alle Funktionen von TenantPilot sicher und vollständig +arbeiten. + +**Why this priority**: Jede neue Funktion kann zusätzliche Berechtigungen benötigen. +Ohne transparente Übersicht und Abgleich besteht das Risiko, dass Features still +kaputt sind oder unsicher laufen. + +**Acceptance Scenarios**: + +1. **Given** ein Tenant ist in TenantPilot hinterlegt, + **When** der Admin die Tenant-Detailseite öffnet, + **Then** sieht er eine Liste aller *erforderlichen* Berechtigungen mit Status + (z. B. OK, fehlt). + +2. **Given** neue Funktionen wurden eingeführt, die zusätzliche Berechtigungen benötigen + und diese wurden in der zentralen Permissions-Liste hinzugefügt, + **When** der Admin die Tenant-Detailseite öffnet, + **Then** erscheinen die neuen Berechtigungen automatisch in der Übersicht und + fehlende Berechtigungen werden klar als fehlend markiert. + +3. **Given** der Admin klickt auf "Verify configuration", + **When** TenantPilot einen Graph-Twestcall und/oder das Permission-Setup prüft, + **Then** wird der Status der Berechtigungen aktualisiert (OK/fehlt/Fehler) und + es wird ein Audit-Eintrag erstellt. + +4. **Given** ein Tenant hat fehlende kritische Berechtigungen, + **When** andere Features (Policy-Sync, Backup, Restore) diesen Tenant verwenden, + **Then** kann TenantPilot dem Admin entsprechende Warnungen anzeigen oder die + Funktion mit einem klaren Fehler abbrechen. + + +**Acceptance Scenarios**: + +1. **Given** a fresh checkout, **When** Sail commands run (`./vendor/bin/sail up -d`, `./vendor/bin/sail artisan migrate`), **Then** the app boots with PostgreSQL and Filament admin available. +2. **Given** a pending release, **When** migrations and restore flows are validated on staging, **Then** production deployment proceeds with documented steps and environment parity. + +### Edge Cases + +- Graph permissions missing or expired, causing policy fetch/restore failures with clear error mapping and audit entries. +- Large policy payloads or many items in a backup set; ensure JSONB storage and pagination handle load without timeouts. +- Restore conflicts when target tenant already has newer versions; preview must surface warnings and allow skip. +- Partial restore failures; audits must capture per-item outcomes and surface retry guidance. +- Diff generation for incompatible or malformed payloads should fail gracefully with admin-facing messaging. +- Retention/size concerns for snapshots; document defaults and guard against unbounded growth. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST list supported Intune policies with normalized metadata and tenant scoping for selection. +- **FR-002**: System MUST allow admins to create backup sets containing multiple policies with immutable JSONB payload snapshots. +- **FR-003**: Backup creation MUST log audit events including actor, timestamp, tenant, items, and outcome. +- **FR-004**: System MUST capture policy versions on demand and present per-policy timelines. +- **FR-005**: Users MUST be able to diff any two versions with a human-readable summary and structured JSON diff where feasible. +- **FR-006**: Restore MUST support preview/dry-run, selective item restore, and explicit confirmation before applying changes. +- **FR-007**: Restore execution MUST produce audit logs covering success, failure, and partial outcomes. +- **FR-008**: Graph integration MUST route through a dedicated abstraction layer with standardized error mapping, safe retries, and high-level logging without secrets. +- **FR-009**: All policy, version, backup, and restore data MUST be tenant-aware; queries enforce tenant isolation. +- **FR-010**: Application MUST run locally via Laravel Sail with PostgreSQL and provide Filament admin flows. +- **FR-011**: Deployments MUST target Dokploy staging before production with documented migration and worker implications. +- **FR-012**: Tests MUST cover backup composition rules, version immutability, audit events, and Filament backup/restore flows (with Graph boundaries mocked). +- **FR-013**: Raw policy snapshots and backup payloads MUST be stored as JSONB with indexes justified by query needs (e.g., FK and time-based; GIN when filters require). +- **FR-014**: UI MUST provide clear warnings for potential restore conflicts and require confirmation for destructive operations. +- **FR-015**: Admins MUST be able to safely delete (archive) backup sets that are no + longer needed. Deletion is implemented as soft-delete with audit logging, and + backup sets referenced by completed restore runs cannot be removed. + +- **FR-016**: Admins MUST be able to delete individual policy versions for housekeeping. + Deletion is implemented as soft-delete with audit logging. + +- **FR-017**: Admins MUST be able to deactivate (soft-delete) a tenant. +Deactivated + tenants: + - do not appear in default lists, + - cannot be used for new sync/backup/restore operations, + - keep their historical data and audit logs for traceability. +- **FR-018**: Admins MAY soft-delete restore runs to keep the UI clean; underlying + backup and policy data remains untouched. + +### Key Entities *(include if feature involves data)* + +- **tenants**: Represents the deployment tenant context; referenced by all scoped data. +- **policies**: Normalized metadata for supported Intune policies. +- **policy_versions**: Immutable snapshots with metadata (actor, timestamp, tenant, policy type). +- **backup_sets**: Group of backup items with creator, timestamp, and tenant context. +- **backup_items**: Individual policy snapshots within a backup set (immutable JSONB payload + identifiers). +- **restore_runs**: Execution records for restores, including preview/actual flags and outcomes. +- **audit_logs**: Audit trail entries for backups, restores, version captures, and significant Graph actions. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Admin can create a backup set selecting multiple policies and view immutable backup items with audit logs in Filament. +- **SC-002**: Policy version history timeline is available per policy and supports comparing any two versions with summary and JSON diff outputs. +- **SC-003**: Restore preview shows change summaries and conflict warnings; execution requires explicit confirmation and produces audit logs for all outcomes. +- **SC-004**: Core flows run locally via Sail; staging validation of migrations and restore paths completes before production deployments. +- **SC-005**: Automated tests covering backup composition, version immutability, audit logging, and Filament backup/restore flows pass via `./vendor/bin/sail artisan test`. + + +### Technical Story – Enforce Single Current Tenant ("Highlander Principle") + +**Context** + +Aktuell können mehrere Tenants `status = active` sein. Graph-Operationen (Policy Sync, +Backup, Restore) wählen den Kontext über Heuristiken (`findOrCreateDefault`, +`local-tenant`), was zu falschen Tenants und Fehlern führt. + +**Goal** + +Es soll **immer genau einen klar definierten "current" Tenant** geben, über den +alle Graph-Operationen laufen. Die Auswahl dieses Tenants ist explizit und +transparent (UI + Env), nicht implizit. + +**Requirements** + +- Es gibt ein Flag `is_current` in `tenants`, das den aktuell verwendeten Kontext + markiert. +- Die Datenbank erzwingt per partiellem Unique Index, dass höchstens ein + nicht-gelöschter Tenant `is_current = true` haben kann. +- `Tenant::current()` liefert: + - falls `INTUNE_TENANT_ID` gesetzt ist, **genau diesen** Tenant (Fehler, wenn + er nicht existiert oder deaktiviert ist), + - sonst den Tenant mit `is_current = true` und `status = active`. + - falls keiner gefunden wird, eine klare Exception (“No current tenant selected”); + es werden keine Dummy-Tenants erzeugt. +- In der Tenant-Verwaltung gibt es eine Action "Make current", die: + - in einer Transaktion alle anderen Tenants auf `is_current = false` setzt + und den gewählten Tenant auf `is_current = true`, + - nur für aktive Tenants verfügbar ist. +- Der frühere Placeholder `local-tenant` darf nicht mehr als Graph-Kontext genutzt + werden; sobald ein echter Tenant existiert, wird er archiviert und ist nie + `is_current`. +- Alle Graph-basierten Funktionen (Policy Sync, Backup, Restore) verwenden + konsistent `Tenant::current()` oder einen explizit übergebenen Tenant. + + Tenant-level actions such as "Admin consent" and "Verify configuration" +MUST be exposed on the tenant detail view (and/or row actions), not as a +global button without explicit tenant context. + + +### UX Guideline – Table Actions / Dropdowns + +- Tabellen in Filament mit mehr als zwei Zeilen-Aktionen (z.B. View, Edit, + Admin consent, Verify, Deactivate, Force delete) MÜSSEN ihre Aktionen in + einem kompakten Dropdown / ActionGroup bündeln, statt alle Buttons nebeneinander + anzuzeigen. +- Ausnahmen: besonders häufige, nicht-destruktive Aktionen (z.B. "View") + dürfen weiterhin als einzelner Button sichtbar bleiben; alle weiteren + Aktionen (z.B. Admin-Aktionen, Housekeeping) sollen im Dropdown liegen. +- Ziel: die Tabellen bleiben übersichtlich, Spaltenbreite wird begrenzt, + und Admins bekommen eine konsistente "⋯"-Interaktion für erweiterte Aktionen. + + diff --git a/storage/debugbar/.gitignore b/storage/debugbar/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/debugbar/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/AdminConsentCallbackTest.php b/tests/Feature/AdminConsentCallbackTest.php new file mode 100644 index 0000000..ca3ec97 --- /dev/null +++ b/tests/Feature/AdminConsentCallbackTest.php @@ -0,0 +1,66 @@ + 'tenant-1', + 'name' => 'Contoso', + ]); + + $response = $this->get(route('admin.consent.callback', [ + 'tenant' => $tenant->tenant_id, + 'admin_consent' => 'true', + ])); + + $response->assertOk(); + + $tenant->refresh(); + expect($tenant->app_status)->toBe('ok'); + + $this->assertDatabaseHas('audit_logs', [ + 'tenant_id' => $tenant->id, + 'action' => 'tenant.consent.callback', + 'status' => 'success', + ]); +}); + +it('creates tenant if not existing and marks pending when onboarded without consent flag', function () { + $response = $this->get(route('admin.consent.callback', [ + 'tenant' => 'new-tenant', + 'state' => 'state-456', + ])); + + $response->assertOk(); + + $tenant = Tenant::where('tenant_id', 'new-tenant')->first(); + expect($tenant)->not->toBeNull(); + expect($tenant->app_status)->toBe('pending'); +}); + +it('records error when consent callback includes error query', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-2', + 'name' => 'Fabrikam', + ]); + + $response = $this->get(route('admin.consent.callback', [ + 'tenant' => $tenant->tenant_id, + 'error' => 'access_denied', + ])); + + $response->assertOk(); + + $tenant->refresh(); + expect($tenant->app_status)->toBe('error'); + expect($tenant->app_notes)->toBe('access_denied'); + + $this->assertDatabaseHas('audit_logs', [ + 'tenant_id' => $tenant->id, + 'action' => 'tenant.consent.callback', + 'status' => 'error', + ]); +}); diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php new file mode 100644 index 0000000..aeb3cc2 --- /dev/null +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -0,0 +1,93 @@ +bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => ['policyId' => $policyId, 'type' => $policyType]]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policyA = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + ]); + + $policyB = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Policy B', + 'platform' => 'windows', + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + $backupSet = BackupSetResource::createBackupSet([ + 'name' => 'Test backup', + ]); + + Livewire::test(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, + ])->callTableAction('addPolicies', data: [ + 'policy_ids' => [$policyA->id, $policyB->id], + ]); + + $backupSet->refresh(); + + expect($backupSet->item_count)->toBe(2); + expect($backupSet->items)->toHaveCount(2); + expect($backupSet->items->first()->payload['policyId'])->toBe('policy-1'); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'backup.created', + 'resource_type' => 'backup_set', + 'resource_id' => (string) $backupSet->id, + ]); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'backup.items_added', + 'resource_type' => 'backup_set', + 'resource_id' => (string) $backupSet->id, + ]); +}); diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php new file mode 100644 index 0000000..25d44b5 --- /dev/null +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -0,0 +1,326 @@ + 'tenant-1', + 'name' => 'Tenant', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set 1', + 'status' => 'completed', + ]); + + BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'payload' => ['id' => 'policy-1'], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListBackupSets::class) + ->callTableAction('archive', $backupSet); + + $this->assertSoftDeleted('backup_sets', ['id' => $backupSet->id]); + $this->assertSoftDeleted('backup_items', ['backup_set_id' => $backupSet->id]); + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'backup_set', + 'resource_id' => (string) $backupSet->id, + 'action' => 'backup.deleted', + ]); +}); + +test('backup set archive is blocked when restore runs exist', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-2', + 'name' => 'Tenant 2', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set with restore', + 'status' => 'completed', + ]); + + RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListBackupSets::class) + ->callTableAction('archive', $backupSet); + + $this->assertDatabaseMissing('audit_logs', [ + 'resource_type' => 'backup_set', + 'resource_id' => (string) $backupSet->id, + 'action' => 'backup.deleted', + ]); + $this->assertDatabaseHas('backup_sets', ['id' => $backupSet->id, 'deleted_at' => null]); +}); + +test('backup set can be force deleted when trashed and unused', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-force', + 'name' => 'Tenant Force', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set force', + 'status' => 'completed', + ]); + + BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'policy-force', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'payload' => ['id' => 'policy-force'], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListBackupSets::class) + ->callTableAction('archive', $backupSet) + ->set('tableFilters.trashed.value', 1) + ->callTableAction('forceDelete', $backupSet); + + $this->assertDatabaseMissing('backup_sets', ['id' => $backupSet->id]); + $this->assertDatabaseMissing('backup_items', ['backup_set_id' => $backupSet->id]); + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'backup_set', + 'resource_id' => (string) $backupSet->id, + 'action' => 'backup.force_deleted', + ]); +}); + +test('restore run can be archived and force deleted', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-restore-run', + 'name' => 'Tenant Restore Run', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set RR', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'completed', + 'is_dry_run' => true, + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListRestoreRuns::class) + ->callTableAction('archive', $restoreRun) + ->set('tableFilters.trashed.value', 1) + ->callTableAction('forceDelete', $restoreRun); + + $this->assertDatabaseMissing('restore_runs', ['id' => $restoreRun->id]); + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'restore_run', + 'resource_id' => (string) $restoreRun->id, + 'action' => 'restore_run.force_deleted', + ]); +}); + +test('policy version can be archived with audit log', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-3', + 'name' => 'Tenant 3', + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'pol-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceConfiguration', + 'snapshot' => ['id' => 'pol-1'], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListPolicyVersions::class) + ->callTableAction('archive', $version); + + $this->assertSoftDeleted('policy_versions', ['id' => $version->id]); + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'policy_version', + 'resource_id' => (string) $version->id, + 'action' => 'policy_version.deleted', + ]); +}); + +test('policy version can be force deleted when trashed', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-3b', + 'name' => 'Tenant 3b', + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'pol-1b', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy B', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceConfiguration', + 'snapshot' => ['id' => 'pol-1b'], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListPolicyVersions::class) + ->callTableAction('archive', $version) + ->set('tableFilters.trashed.value', 1) + ->callTableAction('forceDelete', $version); + + $this->assertDatabaseMissing('policy_versions', ['id' => $version->id]); + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'policy_version', + 'resource_id' => (string) $version->id, + 'action' => 'policy_version.force_deleted', + ]); +}); + +test('tenant can be archived and hidden from default lists', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-4', + 'name' => 'Tenant 4', + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListTenants::class) + ->callTableAction('archive', $tenant); + + expect(Tenant::count())->toBe(0); + + $this->assertSoftDeleted('tenants', ['id' => $tenant->id]); + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'tenant', + 'resource_id' => (string) $tenant->id, + 'action' => 'tenant.archived', + ]); +}); + +test('tenant must be trashed before force delete and removes permanently', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-5', + 'name' => 'Tenant 5', + ]); + + $tenant->delete(); + $tenant->forceDelete(); + + $this->assertDatabaseMissing('tenants', ['id' => $tenant->id]); +}); + +test('tenant table archive filter toggles active and archived tenants', function () { + $active = Tenant::create([ + 'tenant_id' => 'tenant-active', + 'name' => 'Active Tenant', + ]); + + $archived = Tenant::create([ + 'tenant_id' => 'tenant-archived', + 'name' => 'Archived Tenant', + ]); + + $archived->delete(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $component = Livewire::test(ListTenants::class) + ->assertSee($active->name) + ->assertSee($archived->name); + + $component + ->set('tableFilters.trashed.value', null) + ->assertSee($active->name) + ->assertDontSee($archived->name); + + $component + ->set('tableFilters.trashed.value', 0) + ->assertSee($archived->name) + ->assertDontSee($active->name); +}); + +test('archived tenant can be restored from the table', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-restore', + 'name' => 'Restore Tenant', + ]); + + $tenant->delete(); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListTenants::class) + ->set('tableFilters.trashed.value', 1) + ->callTableAction('restore', $tenant); + + $this->assertDatabaseHas('tenants', [ + 'id' => $tenant->id, + 'deleted_at' => null, + 'status' => 'active', + ]); + + $this->assertDatabaseHas('audit_logs', [ + 'resource_type' => 'tenant', + 'resource_id' => (string) $tenant->id, + 'action' => 'tenant.restored', + ]); +}); diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php new file mode 100644 index 0000000..f9c8be1 --- /dev/null +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -0,0 +1,47 @@ + env('INTUNE_TENANT_ID', 'local-tenant'), + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + ]); + + $otherTenant = Tenant::create([ + 'tenant_id' => 'tenant-2', + 'name' => 'Tenant Two', + 'metadata' => [], + ]); + + Policy::create([ + 'tenant_id' => $otherTenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy B', + 'platform' => 'windows', + ]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.policies.index')) + ->assertOk() + ->assertSee('Policy A') + ->assertDontSee('Policy B'); +}); diff --git a/tests/Feature/Filament/PolicyVersionTest.php b/tests/Feature/Filament/PolicyVersionTest.php new file mode 100644 index 0000000..55aed75 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionTest.php @@ -0,0 +1,38 @@ + env('INTUNE_TENANT_ID', 'local-tenant'), + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + ]); + + $service = app(VersionService::class); + $service->captureVersion($policy, ['value' => 1], 'tester'); + $service->captureVersion($policy, ['value' => 2], 'tester'); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.policy-versions.index')) + ->assertOk() + ->assertSee('Policy A') + ->assertSee((string) PolicyVersion::max('version_number')); +}); diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php new file mode 100644 index 0000000..4f8c9cf --- /dev/null +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -0,0 +1,93 @@ +bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['foo' => 'bar'], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'restore.executed', + 'resource_id' => (string) $run->id, + ]); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); +}); diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php new file mode 100644 index 0000000..c61652c --- /dev/null +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -0,0 +1,74 @@ +bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['foo' => 'bar'], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet); + + expect($preview)->toHaveCount(1); + expect($preview[0]['action'])->toBe('update'); +}); diff --git a/tests/Feature/Filament/TenantMakeCurrentTest.php b/tests/Feature/Filament/TenantMakeCurrentTest.php new file mode 100644 index 0000000..8da5bbc --- /dev/null +++ b/tests/Feature/Filament/TenantMakeCurrentTest.php @@ -0,0 +1,40 @@ + 'tenant-one', + 'name' => 'Tenant One', + 'is_current' => true, + ]); + + $second = Tenant::create([ + 'tenant_id' => 'tenant-two', + 'name' => 'Tenant Two', + 'is_current' => false, + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(ListTenants::class) + ->callTableAction('makeCurrent', $second); + + expect(Tenant::find($second->id)->is_current)->toBeTrue(); + expect(Tenant::find($first->id)->is_current)->toBeFalse(); + expect(Tenant::query()->where('is_current', true)->count())->toBe(1); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +}); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php new file mode 100644 index 0000000..8804df8 --- /dev/null +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -0,0 +1,150 @@ +bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, ['value' => [['id' => $options['tenant'] ?? 'tenant']]], 200); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(CreateTenant::class) + ->fillForm([ + 'name' => 'Contoso', + 'tenant_id' => 'tenant-guid', + 'domain' => 'contoso.com', + 'app_client_id' => 'client-123', + 'app_notes' => 'Test tenant', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $tenant = Tenant::first(); + expect($tenant)->not->toBeNull(); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->callAction('verify'); + + $tenant->refresh(); + + expect($tenant->app_status)->toBe('ok'); + + $this->assertDatabaseHas('audit_logs', [ + 'tenant_id' => $tenant->id, + 'action' => 'tenant.config.verified', + 'status' => 'success', + ]); + + $this->assertDatabaseHas('tenant_permissions', [ + 'tenant_id' => $tenant->id, + 'status' => 'ok', + ]); +}); + +test('verify configuration records error when graph fails', function () { + app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 401, ['auth failed']); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $user = User::factory()->create(); + $this->actingAs($user); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-error', + 'name' => 'Error Tenant', + ]); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->callAction('verify'); + + $tenant->refresh(); + + expect($tenant->app_status)->toBe('error'); + + $this->assertDatabaseHas('audit_logs', [ + 'tenant_id' => $tenant->id, + 'action' => 'tenant.config.verified', + 'status' => 'error', + ]); + + $this->assertDatabaseHas('tenant_permissions', [ + 'tenant_id' => $tenant->id, + 'status' => 'error', + ]); +}); + +test('tenant detail shows required permissions with statuses', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-ui', + 'name' => 'UI Tenant', + ]); + + $permissions = config('intune_permissions.permissions', []); + $firstKey = $permissions[0]['key'] ?? 'DeviceManagementConfiguration.ReadWrite.All'; + + TenantPermission::create([ + 'tenant_id' => $tenant->id, + 'permission_key' => $firstKey, + 'status' => 'ok', + ]); + + $response = $this->get(route('filament.admin.resources.tenants.view', $tenant)); + + $response->assertOk(); + $response->assertSee($firstKey); + $response->assertSee('ok'); + $response->assertSee('missing'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a4..d03d034 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->use(RefreshDatabase::class) ->in('Feature'); /* @@ -30,6 +32,14 @@ return $this->toBe(1); }); +function fakeIdToken(string $tenantId): string +{ + $header = rtrim(strtr(base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])), '+/', '-_'), '='); + $payload = rtrim(strtr(base64_encode(json_encode(['tid' => $tenantId])), '+/', '-_'), '='); + + return $header.'.'.$payload.'.signature'; +} + /* |-------------------------------------------------------------------------- | Functions diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..ee63ad0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,7 +4,4 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -abstract class TestCase extends BaseTestCase -{ - // -} +abstract class TestCase extends BaseTestCase {} diff --git a/tests/Unit/GraphClientScopeTest.php b/tests/Unit/GraphClientScopeTest.php new file mode 100644 index 0000000..698e0c6 --- /dev/null +++ b/tests/Unit/GraphClientScopeTest.php @@ -0,0 +1,43 @@ + Http::response([ + 'access_token' => 'token', + 'expires_in' => 3600, + 'token_type' => 'Bearer', + ]), + 'https://graph.microsoft.com/*' => Http::response(['value' => []]), + ]); + + $client = new MicrosoftGraphClient(app(GraphLogger::class)); + + $client->getOrganization(); + + Http::assertSent(function (Request $request): bool { + if (! str_contains($request->url(), '/oauth2/v2.0/token')) { + return false; + } + + return $request['scope'] === 'https://graph.microsoft.com/.default' + && $request['client_id'] === 'client-id' + && $request['grant_type'] === 'client_credentials'; + }); +}); diff --git a/tests/Unit/TenantCurrentTest.php b/tests/Unit/TenantCurrentTest.php new file mode 100644 index 0000000..462d952 --- /dev/null +++ b/tests/Unit/TenantCurrentTest.php @@ -0,0 +1,110 @@ + 'tenant-env', + 'name' => 'Preferred Tenant', + 'is_current' => false, + ]); + + Tenant::create([ + 'tenant_id' => 'other-tenant', + 'name' => 'Other Tenant', + 'is_current' => true, + ]); + + $resolved = Tenant::current(); + + expect($resolved->id)->toBe($preferred->id); + + restoreIntuneTenantId($originalEnv); +}); + +it('throws when env tenant is missing or inactive', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID=missing-tenant'); + + expect(fn () => Tenant::current())->toThrow( + \RuntimeException::class, + 'Configured INTUNE_TENANT_ID tenant is missing or inactive.' + ); + + restoreIntuneTenantId($originalEnv); +}); + +it('returns tenant marked as current when no env override', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + $current = Tenant::create([ + 'tenant_id' => 'tenant-current', + 'name' => 'Current Tenant', + 'is_current' => true, + ]); + + Tenant::create([ + 'tenant_id' => 'tenant-inactive', + 'name' => 'Inactive Current Flag', + 'status' => 'archived', + ]); + + $resolved = Tenant::current(); + + expect($resolved->id)->toBe($current->id); + + restoreIntuneTenantId($originalEnv); +}); + +it('throws when no current tenant is selected', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + Tenant::create([ + 'tenant_id' => 'tenant-one', + 'name' => 'Tenant One', + 'is_current' => false, + ]); + + expect(fn () => Tenant::current())->toThrow(\RuntimeException::class, 'No current tenant selected.'); + + restoreIntuneTenantId($originalEnv); +}); + +it('makeCurrent toggles flags within a transaction', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + $first = Tenant::create([ + 'tenant_id' => 'tenant-first', + 'name' => 'First Tenant', + 'is_current' => true, + ]); + + $second = Tenant::create([ + 'tenant_id' => 'tenant-second', + 'name' => 'Second Tenant', + ]); + + $second->makeCurrent(); + + expect($second->fresh()->is_current)->toBeTrue(); + expect($first->fresh()->is_current)->toBeFalse(); + + restoreIntuneTenantId($originalEnv); +}); diff --git a/tests/Unit/TenantPermissionServiceTest.php b/tests/Unit/TenantPermissionServiceTest.php new file mode 100644 index 0000000..bc5cacc --- /dev/null +++ b/tests/Unit/TenantPermissionServiceTest.php @@ -0,0 +1,95 @@ +getRequiredPermissions(); + + if (empty($required)) { + test()->markTestSkipped('No required permissions configured.'); + } + + return $required; +} + +it('returns ok when all permissions exist', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-ok', + 'name' => 'Tenant OK', + ]); + + foreach (requiredPermissions() as $permission) { + TenantPermission::create([ + 'tenant_id' => $tenant->id, + 'permission_key' => $permission['key'], + 'status' => 'ok', + ]); + } + + $result = app(TenantPermissionService::class)->compare($tenant); + + expect($result['overall_status'])->toBe('ok'); + expect(TenantPermission::where('tenant_id', $tenant->id)->where('status', 'ok')->count()) + ->toBe(count(requiredPermissions())); +}); + +it('marks missing permissions when not granted', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-missing', + 'name' => 'Tenant Missing', + ]); + + $permissions = requiredPermissions(); + $first = $permissions[0]['key']; + TenantPermission::create([ + 'tenant_id' => $tenant->id, + 'permission_key' => $first, + 'status' => 'ok', + ]); + + $result = app(TenantPermissionService::class)->compare($tenant); + + expect($result['overall_status'])->toBe('missing'); + $missingKey = $permissions[1]['key'] ?? null; + + if ($missingKey) { + $this->assertDatabaseHas('tenant_permissions', [ + 'tenant_id' => $tenant->id, + 'permission_key' => $missingKey, + 'status' => 'missing', + ]); + } +}); + +it('reports error statuses from graph comparison', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-error', + 'name' => 'Tenant Error', + ]); + + $permissions = requiredPermissions(); + $first = $permissions[0]['key']; + + $result = app(TenantPermissionService::class)->compare($tenant, [ + $first => [ + 'status' => 'error', + 'details' => ['message' => 'forbidden'], + ], + ]); + + expect($result['overall_status'])->toBe('error'); + + $this->assertDatabaseHas('tenant_permissions', [ + 'tenant_id' => $tenant->id, + 'permission_key' => $first, + 'status' => 'error', + ]); +}); diff --git a/tests/Unit/TenantResourceConsentUrlTest.php b/tests/Unit/TenantResourceConsentUrlTest.php new file mode 100644 index 0000000..c4ea37d --- /dev/null +++ b/tests/Unit/TenantResourceConsentUrlTest.php @@ -0,0 +1,25 @@ + 'https://graph.microsoft.com/.default offline_access openid', + ]); + + $tenant = Tenant::create([ + 'tenant_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf', + 'name' => 'Test Tenant', + 'app_client_id' => 'client-id', + ]); + + $url = TenantResource::adminConsentUrl($tenant); + + expect($url)->toContain('scope='); + expect($url)->toContain(urlencode('https://graph.microsoft.com/.default offline_access openid')); +}); diff --git a/tests/Unit/TenantScopeTest.php b/tests/Unit/TenantScopeTest.php new file mode 100644 index 0000000..846a71f --- /dev/null +++ b/tests/Unit/TenantScopeTest.php @@ -0,0 +1,19 @@ + 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf', + 'name' => 'Test Tenant', + ]); + + $found = Tenant::query()->forTenant('b0091e5d-944f-4a34-bcd9-12cbfb7b75cf')->first(); + + expect($found)->not->toBeNull(); + expect($found->id)->toBe($tenant->id); +}); diff --git a/tests/Unit/VersionDiffTest.php b/tests/Unit/VersionDiffTest.php new file mode 100644 index 0000000..87f43ec --- /dev/null +++ b/tests/Unit/VersionDiffTest.php @@ -0,0 +1,16 @@ +compare( + ['a' => 1, 'b' => ['c' => 2]], + ['a' => 1, 'b.c' => 3, 'd' => 4] + ); + + expect($result['summary']['added'])->toBe(1); + expect($result['summary']['changed'])->toBe(1); + expect($result['summary']['removed'])->toBe(0); +});