|
7 | 7 | use Illuminate\Contracts\Auth\Authenticatable; |
8 | 8 | use Illuminate\Database\Eloquent\Model; |
9 | 9 | use Illuminate\Support\Collection; |
| 10 | +use OffloadProject\Mandate\CodeFirst\DefinitionCache; |
| 11 | +use OffloadProject\Mandate\CodeFirst\DefinitionDiscoverer; |
10 | 12 | use OffloadProject\Mandate\Concerns\HasRoles; |
11 | 13 | use OffloadProject\Mandate\Contracts\Capability as CapabilityContract; |
12 | 14 | use OffloadProject\Mandate\Contracts\FeatureAccessHandler; |
13 | 15 | use OffloadProject\Mandate\Contracts\Permission as PermissionContract; |
14 | 16 | use OffloadProject\Mandate\Contracts\Role as RoleContract; |
| 17 | +use OffloadProject\Mandate\Events\CapabilitiesSynced; |
| 18 | +use OffloadProject\Mandate\Events\MandateSynced; |
| 19 | +use OffloadProject\Mandate\Events\PermissionsSynced; |
| 20 | +use OffloadProject\Mandate\Events\RolesSynced; |
15 | 21 | use OffloadProject\Mandate\Models\Capability; |
16 | 22 | use OffloadProject\Mandate\Models\Permission; |
17 | 23 | use OffloadProject\Mandate\Models\Role; |
@@ -649,6 +655,343 @@ public function getAuthorizationData(?Authenticatable $subject = null, ?Model $c |
649 | 655 | return $data; |
650 | 656 | } |
651 | 657 |
|
| 658 | + /** |
| 659 | + * Sync code-first definitions to the database and optionally seed assignments. |
| 660 | + * |
| 661 | + * This method programmatically performs the same operations as the `mandate:sync` command. |
| 662 | + * |
| 663 | + * @param bool $permissions Sync only permissions (if true without roles/capabilities, only permissions are synced) |
| 664 | + * @param bool $roles Sync only roles (if true without permissions/capabilities, only roles are synced) |
| 665 | + * @param bool $capabilities Sync only capabilities (if true without permissions/roles, only capabilities are synced) |
| 666 | + * @param bool $seed Seed role-permission and role-capability assignments from config |
| 667 | + * @param string|null $guard Sync for a specific guard only |
| 668 | + * |
| 669 | + * @throws RuntimeException If code-first mode is not enabled (unless using seed-only mode) |
| 670 | + */ |
| 671 | + public function sync( |
| 672 | + bool $permissions = false, |
| 673 | + bool $roles = false, |
| 674 | + bool $capabilities = false, |
| 675 | + bool $seed = false, |
| 676 | + ?string $guard = null, |
| 677 | + ): SyncResult { |
| 678 | + $codeFirstEnabled = (bool) config('mandate.code_first.enabled', false); |
| 679 | + $seedOnly = $seed && ! $permissions && ! $roles && ! $capabilities; |
| 680 | + |
| 681 | + // Allow seed to work without code-first enabled |
| 682 | + if (! $codeFirstEnabled && ! $seedOnly) { |
| 683 | + throw new RuntimeException('Code-first mode is not enabled. Set mandate.code_first.enabled to true.'); |
| 684 | + } |
| 685 | + |
| 686 | + $permissionsCreated = 0; |
| 687 | + $permissionsUpdated = 0; |
| 688 | + $rolesCreated = 0; |
| 689 | + $rolesUpdated = 0; |
| 690 | + $capabilitiesCreated = 0; |
| 691 | + $capabilitiesUpdated = 0; |
| 692 | + |
| 693 | + $syncAll = ! $permissions && ! $roles && ! $capabilities && ! $seedOnly; |
| 694 | + |
| 695 | + // Determine what to sync (skip if seed-only mode) |
| 696 | + $syncPermissions = $codeFirstEnabled && ($syncAll || $permissions); |
| 697 | + $syncRoles = $codeFirstEnabled && ($syncAll || $roles); |
| 698 | + $syncCapabilities = $codeFirstEnabled && ($syncAll || $capabilities) && $this->capabilitiesEnabled(); |
| 699 | + |
| 700 | + // Only instantiate code-first dependencies when actually needed |
| 701 | + if ($syncPermissions || $syncRoles || $syncCapabilities) { |
| 702 | + $discoverer = app(DefinitionDiscoverer::class); |
| 703 | + $cache = app(DefinitionCache::class); |
| 704 | + |
| 705 | + // Sync permissions |
| 706 | + if ($syncPermissions) { |
| 707 | + $result = $this->syncDefinitionsToDatabase($discoverer, 'permissions', $guard); |
| 708 | + $permissionsCreated = $result['created']; |
| 709 | + $permissionsUpdated = $result['updated']; |
| 710 | + } |
| 711 | + |
| 712 | + // Sync roles |
| 713 | + if ($syncRoles) { |
| 714 | + $result = $this->syncDefinitionsToDatabase($discoverer, 'roles', $guard); |
| 715 | + $rolesCreated = $result['created']; |
| 716 | + $rolesUpdated = $result['updated']; |
| 717 | + } |
| 718 | + |
| 719 | + // Sync capabilities |
| 720 | + if ($syncCapabilities) { |
| 721 | + $result = $this->syncDefinitionsToDatabase($discoverer, 'capabilities', $guard); |
| 722 | + $capabilitiesCreated = $result['created']; |
| 723 | + $capabilitiesUpdated = $result['updated']; |
| 724 | + } |
| 725 | + |
| 726 | + // Clear definition cache |
| 727 | + $cache->forget(); |
| 728 | + } |
| 729 | + |
| 730 | + // Seed assignments if requested |
| 731 | + $assignmentsSeeded = false; |
| 732 | + if ($seed) { |
| 733 | + $this->seedAssignmentsFromConfig($guard); |
| 734 | + $assignmentsSeeded = true; |
| 735 | + } |
| 736 | + |
| 737 | + // Clear permission cache (always needed after any sync/seed operation) |
| 738 | + $this->registrar->forgetCachedPermissions(); |
| 739 | + |
| 740 | + // Dispatch events only when actual sync operations were performed |
| 741 | + $anySyncPerformed = $syncPermissions || $syncRoles || $syncCapabilities; |
| 742 | + |
| 743 | + if ($anySyncPerformed) { |
| 744 | + // Create event objects |
| 745 | + $permissionsEvent = new PermissionsSynced($permissionsCreated, $permissionsUpdated, collect()); |
| 746 | + $rolesEvent = new RolesSynced($rolesCreated, $rolesUpdated, collect()); |
| 747 | + $capabilitiesEvent = $syncCapabilities |
| 748 | + ? new CapabilitiesSynced($capabilitiesCreated, $capabilitiesUpdated, collect()) |
| 749 | + : null; |
| 750 | + |
| 751 | + // Dispatch individual events only for types that were actually synced |
| 752 | + if ($syncPermissions) { |
| 753 | + event($permissionsEvent); |
| 754 | + } |
| 755 | + if ($syncRoles) { |
| 756 | + event($rolesEvent); |
| 757 | + } |
| 758 | + if ($capabilitiesEvent) { |
| 759 | + event($capabilitiesEvent); |
| 760 | + } |
| 761 | + |
| 762 | + // Dispatch combined event |
| 763 | + event(new MandateSynced($permissionsEvent, $rolesEvent, $capabilitiesEvent)); |
| 764 | + } |
| 765 | + |
| 766 | + return new SyncResult( |
| 767 | + permissionsCreated: $permissionsCreated, |
| 768 | + permissionsUpdated: $permissionsUpdated, |
| 769 | + rolesCreated: $rolesCreated, |
| 770 | + rolesUpdated: $rolesUpdated, |
| 771 | + capabilitiesCreated: $capabilitiesCreated, |
| 772 | + capabilitiesUpdated: $capabilitiesUpdated, |
| 773 | + assignmentsSeeded: $assignmentsSeeded, |
| 774 | + ); |
| 775 | + } |
| 776 | + |
| 777 | + /** |
| 778 | + * Sync definitions to the database for a given entity type. |
| 779 | + * |
| 780 | + * @param 'permissions'|'roles'|'capabilities' $entityType |
| 781 | + * @return array{created: int, updated: int} |
| 782 | + */ |
| 783 | + private function syncDefinitionsToDatabase( |
| 784 | + DefinitionDiscoverer $discoverer, |
| 785 | + string $entityType, |
| 786 | + ?string $guard |
| 787 | + ): array { |
| 788 | + $paths = config("mandate.code_first.paths.{$entityType}", []); |
| 789 | + $paths = is_array($paths) ? $paths : [$paths]; |
| 790 | + |
| 791 | + // Discover definitions based on entity type |
| 792 | + $definitions = match ($entityType) { |
| 793 | + 'permissions' => $discoverer->discoverPermissions($paths), |
| 794 | + 'roles' => $discoverer->discoverRoles($paths), |
| 795 | + 'capabilities' => $discoverer->discoverCapabilities($paths), |
| 796 | + }; |
| 797 | + |
| 798 | + // Filter by guard if specified |
| 799 | + if ($guard !== null) { |
| 800 | + $definitions = $definitions->filter(fn ($d) => $d->guard === $guard); |
| 801 | + } |
| 802 | + |
| 803 | + // Get the model class and check for label column |
| 804 | + $modelConfigKey = match ($entityType) { |
| 805 | + 'permissions' => 'permission', |
| 806 | + 'roles' => 'role', |
| 807 | + 'capabilities' => 'capability', |
| 808 | + }; |
| 809 | + $defaultClass = match ($entityType) { |
| 810 | + 'permissions' => Permission::class, |
| 811 | + 'roles' => Role::class, |
| 812 | + 'capabilities' => Capability::class, |
| 813 | + }; |
| 814 | + |
| 815 | + /** @var class-string<Permission|Role|Capability> $modelClass */ |
| 816 | + $modelClass = config("mandate.models.{$modelConfigKey}", $defaultClass); |
| 817 | + $hasLabelColumn = $modelClass::hasLabelColumn(); |
| 818 | + |
| 819 | + $created = 0; |
| 820 | + $updated = 0; |
| 821 | + |
| 822 | + foreach ($definitions as $definition) { |
| 823 | + $existing = $modelClass::query() |
| 824 | + ->where('name', $definition->name) |
| 825 | + ->where('guard', $definition->guard) |
| 826 | + ->first(); |
| 827 | + |
| 828 | + if ($existing) { |
| 829 | + $needsUpdate = false; |
| 830 | + $updates = []; |
| 831 | + |
| 832 | + if ($hasLabelColumn) { |
| 833 | + if ($definition->label !== null && $existing->label !== $definition->label) { |
| 834 | + $updates['label'] = $definition->label; |
| 835 | + $needsUpdate = true; |
| 836 | + } |
| 837 | + if ($definition->description !== null && $existing->description !== $definition->description) { |
| 838 | + $updates['description'] = $definition->description; |
| 839 | + $needsUpdate = true; |
| 840 | + } |
| 841 | + } |
| 842 | + |
| 843 | + if ($needsUpdate) { |
| 844 | + $existing->update($updates); |
| 845 | + $updated++; |
| 846 | + } |
| 847 | + } else { |
| 848 | + $attributes = [ |
| 849 | + 'name' => $definition->name, |
| 850 | + 'guard' => $definition->guard, |
| 851 | + ]; |
| 852 | + |
| 853 | + if ($hasLabelColumn) { |
| 854 | + $attributes['label'] = $definition->label; |
| 855 | + $attributes['description'] = $definition->description; |
| 856 | + } |
| 857 | + |
| 858 | + $modelClass::query()->create($attributes); |
| 859 | + $created++; |
| 860 | + } |
| 861 | + } |
| 862 | + |
| 863 | + return ['created' => $created, 'updated' => $updated]; |
| 864 | + } |
| 865 | + |
| 866 | + /** |
| 867 | + * Seed role-permission and role-capability assignments from config. |
| 868 | + */ |
| 869 | + private function seedAssignmentsFromConfig(?string $guard): void |
| 870 | + { |
| 871 | + /** @var array<string, array{permissions?: array<string>, capabilities?: array<string>}> $assignments */ |
| 872 | + $assignments = config('mandate.assignments', []); |
| 873 | + |
| 874 | + if (empty($assignments)) { |
| 875 | + return; |
| 876 | + } |
| 877 | + |
| 878 | + $roleGuard = $guard ?? config('auth.defaults.guard', 'web'); |
| 879 | + |
| 880 | + /** @var class-string<Role> $roleClass */ |
| 881 | + $roleClass = config('mandate.models.role', Role::class); |
| 882 | + /** @var class-string<Permission> $permissionClass */ |
| 883 | + $permissionClass = config('mandate.models.permission', Permission::class); |
| 884 | + /** @var class-string<Capability> $capabilityClass */ |
| 885 | + $capabilityClass = config('mandate.models.capability', Capability::class); |
| 886 | + |
| 887 | + // Collect all unique names needed |
| 888 | + $roleNames = array_keys($assignments); |
| 889 | + $allPermissionNames = []; |
| 890 | + $allCapabilityNames = []; |
| 891 | + |
| 892 | + foreach ($assignments as $assignment) { |
| 893 | + if (! empty($assignment['permissions'])) { |
| 894 | + $allPermissionNames = array_merge($allPermissionNames, $assignment['permissions']); |
| 895 | + } |
| 896 | + if (! empty($assignment['capabilities'])) { |
| 897 | + $allCapabilityNames = array_merge($allCapabilityNames, $assignment['capabilities']); |
| 898 | + } |
| 899 | + } |
| 900 | + |
| 901 | + $allPermissionNames = array_unique($allPermissionNames); |
| 902 | + $allCapabilityNames = array_unique($allCapabilityNames); |
| 903 | + |
| 904 | + // Batch fetch existing roles |
| 905 | + /** @var Collection<int, Role> $existingRoles */ |
| 906 | + $existingRoles = $roleClass::query() |
| 907 | + ->whereIn('name', $roleNames) |
| 908 | + ->where('guard', $roleGuard) |
| 909 | + ->get() |
| 910 | + ->keyBy('name'); |
| 911 | + |
| 912 | + // Create missing roles |
| 913 | + $rolesMap = []; |
| 914 | + foreach ($roleNames as $roleName) { |
| 915 | + if ($existingRoles->has($roleName)) { |
| 916 | + $rolesMap[$roleName] = $existingRoles->get($roleName); |
| 917 | + } else { |
| 918 | + $rolesMap[$roleName] = $roleClass::create([ |
| 919 | + 'name' => $roleName, |
| 920 | + 'guard' => $roleGuard, |
| 921 | + ]); |
| 922 | + } |
| 923 | + } |
| 924 | + |
| 925 | + // Batch fetch existing permissions (skip if none needed) |
| 926 | + $permissionsMap = []; |
| 927 | + if (! empty($allPermissionNames)) { |
| 928 | + /** @var Collection<int, Permission> $existingPermissions */ |
| 929 | + $existingPermissions = $permissionClass::query() |
| 930 | + ->whereIn('name', $allPermissionNames) |
| 931 | + ->where('guard', $roleGuard) |
| 932 | + ->get() |
| 933 | + ->keyBy('name'); |
| 934 | + |
| 935 | + // Create missing permissions |
| 936 | + foreach ($allPermissionNames as $permissionName) { |
| 937 | + if ($existingPermissions->has($permissionName)) { |
| 938 | + $permissionsMap[$permissionName] = $existingPermissions->get($permissionName); |
| 939 | + } else { |
| 940 | + $permissionsMap[$permissionName] = $permissionClass::create([ |
| 941 | + 'name' => $permissionName, |
| 942 | + 'guard' => $roleGuard, |
| 943 | + ]); |
| 944 | + } |
| 945 | + } |
| 946 | + } |
| 947 | + |
| 948 | + // Batch fetch existing capabilities (if enabled) |
| 949 | + $capabilitiesMap = []; |
| 950 | + if ($this->capabilitiesEnabled() && ! empty($allCapabilityNames)) { |
| 951 | + /** @var Collection<int, Capability> $existingCapabilities */ |
| 952 | + $existingCapabilities = $capabilityClass::query() |
| 953 | + ->whereIn('name', $allCapabilityNames) |
| 954 | + ->where('guard', $roleGuard) |
| 955 | + ->get() |
| 956 | + ->keyBy('name'); |
| 957 | + |
| 958 | + foreach ($allCapabilityNames as $capabilityName) { |
| 959 | + if ($existingCapabilities->has($capabilityName)) { |
| 960 | + $capabilitiesMap[$capabilityName] = $existingCapabilities->get($capabilityName); |
| 961 | + } else { |
| 962 | + $capabilitiesMap[$capabilityName] = $capabilityClass::create([ |
| 963 | + 'name' => $capabilityName, |
| 964 | + 'guard' => $roleGuard, |
| 965 | + ]); |
| 966 | + } |
| 967 | + } |
| 968 | + } |
| 969 | + |
| 970 | + // Assign permissions and capabilities to roles |
| 971 | + foreach ($assignments as $roleName => $assignment) { |
| 972 | + /** @var Role $role */ |
| 973 | + $role = $rolesMap[$roleName]; |
| 974 | + |
| 975 | + // Sync permissions |
| 976 | + if (! empty($assignment['permissions'])) { |
| 977 | + $permissionIds = array_map( |
| 978 | + fn (string $name) => $permissionsMap[$name]->getKey(), |
| 979 | + $assignment['permissions'] |
| 980 | + ); |
| 981 | + $role->permissions()->syncWithoutDetaching($permissionIds); |
| 982 | + } |
| 983 | + |
| 984 | + // Sync capabilities |
| 985 | + if ($this->capabilitiesEnabled() && ! empty($assignment['capabilities'])) { |
| 986 | + $capabilityIds = array_map( |
| 987 | + fn (string $name) => $capabilitiesMap[$name]->getKey(), |
| 988 | + $assignment['capabilities'] |
| 989 | + ); |
| 990 | + $role->capabilities()->syncWithoutDetaching($capabilityIds); |
| 991 | + } |
| 992 | + } |
| 993 | + } |
| 994 | + |
652 | 995 | /** |
653 | 996 | * Handle the case when feature handler is not available. |
654 | 997 | */ |
|
0 commit comments