• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / auth / 23341393117

20 Mar 2026 11:45AM UTC coverage: 64.989% (+1.2%) from 63.745%
23341393117

push

github

daycry
Merge branch 'development' of https://github.com/daycry/auth into development

4 of 4 new or added lines in 2 files covered. (100.0%)

315 existing lines in 13 files now uncovered.

3306 of 5087 relevant lines covered (64.99%)

47.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.48
/src/Traits/Authorizable.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Auth.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace Daycry\Auth\Traits;
15

16
use CodeIgniter\Exceptions\LogicException;
17
use CodeIgniter\I18n\Time;
18
use Config\Database;
19
use Daycry\Auth\Entities\Group;
20
use Daycry\Auth\Exceptions\AuthorizationException;
21
use Daycry\Auth\Models\GroupModel;
22
use Daycry\Auth\Models\GroupUserModel;
23
use Daycry\Auth\Models\PermissionGroupModel;
24
use Daycry\Auth\Models\PermissionModel;
25
use Daycry\Auth\Models\PermissionUserModel;
26

27
trait Authorizable
28
{
29
    protected ?array $groupCache       = null;
30
    protected ?array $permissionsCache = null;
31
    protected ?array $groups           = null;
32
    protected ?array $permissions      = null;
33

34
    /**
35
     * @var array<string, list<string>>|null Group name => list of permission names
36
     */
37
    protected ?array $groupPermissionsCache = null;
38

39
    private function groupModel(): GroupModel
49✔
40
    {
41
        /** @var GroupModel */
42
        return model(GroupModel::class);
49✔
43
    }
44

45
    private function groupUserModel(): GroupUserModel
49✔
46
    {
47
        /** @var GroupUserModel */
48
        return model(GroupUserModel::class);
49✔
49
    }
50

51
    private function permissionModel(): PermissionModel
30✔
52
    {
53
        /** @var PermissionModel */
54
        return model(PermissionModel::class);
30✔
55
    }
56

57
    private function permissionUserModel(): PermissionUserModel
30✔
58
    {
59
        /** @var PermissionUserModel */
60
        return model(PermissionUserModel::class);
30✔
61
    }
62

63
    private function permissionGroupModel(): PermissionGroupModel
24✔
64
    {
65
        /** @var PermissionGroupModel */
66
        return model(PermissionGroupModel::class);
24✔
67
    }
68

69
    /**
70
     * Adds one or more groups to the current User.
71
     *
72
     * @return $this
73
     */
74
    public function addGroup(string ...$groups): self
28✔
75
    {
76
        $this->populateGroups();
28✔
77

78
        $groupCount = count($this->groupCache);
28✔
79

80
        foreach ($groups as $group) {
28✔
81
            $group = strtolower($group);
28✔
82

83
            // don't allow dupes
84
            if (in_array($group, $this->groupCache, true)) {
28✔
85
                continue;
2✔
86
            }
87

88
            $groupsNames = ($this->groups) ? array_values($this->groups) : [];
27✔
89

90
            // make sure it's a valid group
91
            if (! in_array($group, $groupsNames, true)) {
27✔
92
                throw AuthorizationException::forUnknownGroup($group);
2✔
93
            }
94

95
            $this->groupCache[] = $group;
26✔
96
        }
97

98
        // Only save the results if there's anything new.
99
        if (count($this->groupCache) > $groupCount) {
26✔
100
            $this->saveGroups();
25✔
101
        }
102

103
        return $this;
26✔
104
    }
105

106
    /**
107
     * Removes one or more groups from the user.
108
     *
109
     * @return $this
110
     */
111
    public function removeGroup(string ...$groups): self
5✔
112
    {
113
        $this->populateGroups();
5✔
114

115
        foreach ($groups as &$group) {
5✔
116
            $group = strtolower($group);
5✔
117
        }
118

119
        // Remove from local cache
120
        $this->groupCache = array_diff($this->groupCache, $groups);
5✔
121

122
        // Update the database.
123
        $this->saveGroups();
5✔
124

125
        return $this;
5✔
126
    }
127

128
    /**
129
     * Given an array of groups, will update the database
130
     * so only those groups are valid for this user, removing
131
     * all groups not in this list.
132
     *
133
     * @return $this
134
     *
135
     * @throws AuthorizationException
136
     */
137
    public function syncGroups(string ...$groups): self
2✔
138
    {
139
        $this->populateGroups();
2✔
140

141
        foreach ($groups as $group) {
2✔
142
            if (! in_array($group, array_values($this->groups), true)) {
2✔
143
                throw AuthorizationException::forUnknownGroup($group);
1✔
144
            }
145
        }
146

147
        $this->groupCache = $groups;
1✔
148
        $this->saveGroups();
1✔
149

150
        return $this;
1✔
151
    }
152

153
    /**
154
     * Returns all groups this user is a part of.
155
     */
156
    public function getGroups(): ?array
7✔
157
    {
158
        $this->populateGroups();
7✔
159

160
        return array_values($this->groupCache);
7✔
161
    }
162

163
    /**
164
     * Returns all permissions this user has
165
     * assigned directly to them.
166
     */
167
    public function getPermissions(): ?array
12✔
168
    {
169
        $this->populatePermissions();
12✔
170

171
        return array_values($this->permissionsCache);
12✔
172
    }
173

174
    /**
175
     * Adds one or more permissions to the current user.
176
     *
177
     * @return $this
178
     *
179
     * @throws AuthorizationException
180
     */
181
    public function addPermission(string ...$permissions): self
17✔
182
    {
183
        $this->populatePermissions();
17✔
184

185
        $permissionCount = count($this->permissionsCache);
17✔
186

187
        foreach ($permissions as $permission) {
17✔
188
            $permission = strtolower($permission);
17✔
189

190
            // don't allow dupes
191
            if (in_array($permission, $this->permissionsCache, true)) {
17✔
192
                continue;
3✔
193
            }
194

195
            $permissionsNames = ($this->permissions) ? array_values($this->permissions) : [];
15✔
196

197
            // make sure it's a valid group
198
            if (! in_array($permission, $permissionsNames, true)) {
15✔
199
                throw AuthorizationException::forUnknownPermission($permission);
2✔
200
            }
201

202
            $this->permissionsCache[] = $permission;
13✔
203
        }
204

205
        // Only save the results if there's anything new.
206
        if (count($this->permissionsCache) > $permissionCount) {
15✔
207
            $this->savePermissions();
13✔
208
        }
209

210
        return $this;
15✔
211
    }
212

213
    /**
214
     * Removes one or more permissions from the current user.
215
     *
216
     * @return $this
217
     */
218
    public function removePermission(string ...$permissions): self
5✔
219
    {
220
        $this->populatePermissions();
5✔
221

222
        foreach ($permissions as &$permission) {
5✔
223
            $permission = strtolower($permission);
5✔
224
        }
225

226
        // Remove from local cache
227
        $this->permissionsCache = array_diff($this->permissionsCache, $permissions);
5✔
228

229
        // Update the database.
230
        $this->savePermissions();
5✔
231

232
        return $this;
5✔
233
    }
234

235
    /**
236
     * Given an array of permissions, will update the database
237
     * so only those permissions are valid for this user, removing
238
     * all permissions not in this list.
239
     *
240
     * @return $this
241
     *
242
     * @throws AuthorizationException
243
     */
244
    public function syncPermissions(string ...$permissions): self
2✔
245
    {
246
        $this->populatePermissions();
2✔
247

248
        foreach ($permissions as $permission) {
2✔
249
            if (! in_array($permission, array_values($this->permissions), true)) {
2✔
250
                throw AuthorizationException::forUnknownPermission($permission);
1✔
251
            }
252
        }
253

254
        $this->permissionsCache = $permissions;
1✔
255
        $this->savePermissions();
1✔
256

257
        return $this;
1✔
258
    }
259

260
    /**
261
     * Checks to see if the user has the permission set
262
     * directly on themselves. This disregards any groups
263
     * they are part of.
264
     */
265
    public function hasPermission(string $permission): bool
5✔
266
    {
267
        $this->populatePermissions();
5✔
268

269
        $permission = strtolower($permission);
5✔
270

271
        return in_array($permission, $this->permissionsCache, true);
5✔
272
    }
273

274
    /**
275
     * Checks user permissions and their group permissions
276
     * to see if the user has a specific permission or group
277
     * of permissions.
278
     *
279
     * @param string $permissions string(s) consisting of a scope and action, like `users.create`
280
     */
281
    public function can(string ...$permissions): bool
16✔
282
    {
283
        // Get user's permissions and store in cache
284
        $this->populatePermissions();
16✔
285

286
        // Check the groups the user belongs to
287
        $this->populateGroups();
16✔
288

289
        foreach ($permissions as $permission) {
16✔
290
            // Permission must contain a scope and action
291
            if (! str_contains($permission, '.')) {
16✔
292
                throw new LogicException(
2✔
293
                    'A permission must be a string consisting of a scope and action, like `users.create`.'
2✔
294
                    . ' Invalid permission: ' . $permission,
2✔
295
                );
2✔
296
            }
297

298
            $permission = strtolower($permission);
14✔
299

300
            if (in_array('*', $this->permissionsCache, true)) {
14✔
UNCOV
301
                return true;
×
302
            }
303

304
            // Check user's permissions
305
            if (in_array($permission, $this->permissionsCache, true)) {
14✔
306
                return true;
7✔
307
            }
308

309
            if (count($this->groupCache) === 0) {
12✔
310
                return false;
8✔
311
            }
312

313
            foreach ($this->groupCache as $groupName) {
5✔
314
                $groupPermNames = $this->groupPermissionsCache[$groupName] ?? [];
5✔
315

316
                if (in_array('*', $groupPermNames, true)) {
5✔
UNCOV
317
                    return true;
×
318
                }
319

320
                // Check exact match
321
                if (in_array($permission, $groupPermNames, true)) {
5✔
322
                    return true;
3✔
323
                }
324

325
                // Check wildcard match (e.g. 'users.*' covers 'users.edit')
326
                $check = substr($permission, 0, strpos($permission, '.')) . '.*';
4✔
327
                if (in_array($check, $groupPermNames, true)) {
4✔
328
                    return true;
4✔
329
                }
330
            }
331
        }
332

333
        return false;
2✔
334
    }
335

336
    /**
337
     * Checks to see if the user is a member of one
338
     * of the groups passed in.
339
     */
340
    public function inGroup(string ...$groups): bool
15✔
341
    {
342
        $this->populateGroups();
15✔
343

344
        foreach ($groups as $group) {
15✔
345
            if (in_array(strtolower($group), $this->groupCache, true)) {
15✔
346
                return true;
10✔
347
            }
348
        }
349

350
        return false;
10✔
351
    }
352

353
    /**
354
     * User for populate all groups
355
     */
356
    private function getAllUserGroups(): array
49✔
357
    {
358
        $userGroupModel = $this->groupUserModel();
49✔
359
        $userGroups     = $userGroupModel->getForUser($this);
49✔
360

361
        $ids = [];
49✔
362

363
        foreach ($userGroups as $userGroup) {
49✔
364
            $ids[] = $userGroup->group_id;
24✔
365
        }
366

367
        $groupModel = $this->groupModel();
49✔
368

369
        if ($ids !== []) {
49✔
370
            return $groupModel->getByIds($ids);
24✔
371
        }
372

373
        return [];
42✔
374
    }
375

376
    /**
377
     * User for populate all permissions
378
     */
379
    private function getAllUserPermissions(): array
30✔
380
    {
381
        $userPermissionsModel = $this->permissionUserModel();
30✔
382
        $userPermissions      = $userPermissionsModel->getForUser($this);
30✔
383

384
        $ids = [];
30✔
385

386
        foreach ($userPermissions as $userPermission) {
30✔
387
            $ids[] = $userPermission->permission_id;
15✔
388
        }
389

390
        $permissionModel = $this->permissionModel();
30✔
391

392
        if ($ids !== []) {
30✔
393
            return $permissionModel->getByIds($ids);
15✔
394
        }
395

396
        return [];
21✔
397
    }
398

399
    /**
400
     * User for populate all permissions
401
     */
UNCOV
402
    private function getGroupPermissions(Group $group): array
×
403
    {
UNCOV
404
        $groupPermissionsModel = $this->permissionGroupModel();
×
UNCOV
405
        $groupPermissions      = $groupPermissionsModel->getForGroup($group);
×
406

UNCOV
407
        $ids = [];
×
408

UNCOV
409
        foreach ($groupPermissions as $groupPermission) {
×
UNCOV
410
            $ids[] = $groupPermission->permission_id;
×
411
        }
412

UNCOV
413
        $permissionModel = $this->permissionModel();
×
414

UNCOV
415
        if ($ids !== []) {
×
UNCOV
416
            return $permissionModel->getByIds($ids);
×
417
        }
418

UNCOV
419
        return [];
×
420
    }
421

422
    /**
423
     * Loads all permissions for all of the user's groups in two queries
424
     * (one for permission_group rows, one for permission names) and
425
     * populates $groupPermissionsCache. This eliminates the N+1 problem
426
     * where can() previously called getGroupPermissions() per group.
427
     */
428
    private function eagerLoadGroupPermissions(): void
24✔
429
    {
430
        // Resolve group IDs from group names
431
        $groupIds = [];
24✔
432

433
        foreach ($this->groupCache as $groupName) {
24✔
434
            $id = array_search($groupName, $this->groups, true);
24✔
435

436
            if ($id !== false) {
24✔
437
                $groupIds[] = $id;
24✔
438
            }
439
        }
440

441
        if ($groupIds === []) {
24✔
UNCOV
442
            return;
×
443
        }
444

445
        // Single query: get all permission_group rows for all user groups (respecting until_at)
446
        $groupPermModel = $this->permissionGroupModel();
24✔
447
        $now            = Time::now()->format('Y-m-d H:i:s');
24✔
448

449
        $allGroupPerms = $groupPermModel
24✔
450
            ->whereIn('group_id', $groupIds)
24✔
451
            ->groupStart()
24✔
452
            ->where('until_at')
24✔
453
            ->orWhere('until_at >', $now)
24✔
454
            ->groupEnd()
24✔
455
            ->findAll();
24✔
456

457
        // Build a map of group_id => [permission_id, ...] and collect all permission IDs
458
        $permIds      = [];
24✔
459
        $groupPermMap = [];
24✔
460

461
        foreach ($allGroupPerms as $gp) {
24✔
462
            $pid                  = (int) $gp->permission_id;
5✔
463
            $gid                  = (int) $gp->group_id;
5✔
464
            $permIds[]            = $pid;
5✔
465
            $groupPermMap[$gid][] = $pid;
5✔
466
        }
467

468
        // Single query: get all permission names by IDs
469
        $permNames = [];
24✔
470

471
        if ($permIds !== []) {
24✔
472
            $permModel = $this->permissionModel();
5✔
473
            $perms     = $permModel->getByIds(array_unique($permIds));
5✔
474

475
            foreach ($perms as $p) {
5✔
476
                $permNames[(int) $p->id] = $p->name;
5✔
477
            }
478
        }
479

480
        // Build the cache: groupName => [permissionName, ...]
481
        foreach ($this->groupCache as $groupName) {
24✔
482
            $gId                                     = array_search($groupName, $this->groups, true);
24✔
483
            $this->groupPermissionsCache[$groupName] = [];
24✔
484

485
            if ($gId !== false && isset($groupPermMap[(int) $gId])) {
24✔
486
                foreach ($groupPermMap[(int) $gId] as $pid) {
5✔
487
                    if (isset($permNames[$pid])) {
5✔
488
                        $this->groupPermissionsCache[$groupName][] = $permNames[$pid];
5✔
489
                    }
490
                }
491
            }
492
        }
493
    }
494

495
    /**
496
     * Used internally to populate the User groups
497
     * so we hit the database as little as possible.
498
     * Reads from persistent cache when permissionCacheEnabled is true.
499
     */
500
    private function populateGroups(): void
49✔
501
    {
502
        if (is_array($this->groupCache) && is_array($this->groups) && is_array($this->groupPermissionsCache)) {
49✔
503
            return;
15✔
504
        }
505

506
        // Try persistent cache first
507
        if (service('settings')->get('AuthSecurity.permissionCacheEnabled')) {
49✔
508
            /** @var array{groups: array<int, string>, groupCache: list<string>, groupPermissionsCache: array<string, list<string>>}|null $cached */
509
            $cached = cache($this->getPermissionCacheKey('groups'));
5✔
510

511
            if ($cached !== null) {
5✔
UNCOV
512
                $this->groups                = $cached['groups'];
×
UNCOV
513
                $this->groupCache            = $cached['groupCache'];
×
UNCOV
514
                $this->groupPermissionsCache = $cached['groupPermissionsCache'];
×
515

UNCOV
516
                return;
×
517
            }
518
        }
519

520
        $groupModel = $this->groupModel();
49✔
521
        $rows       = $groupModel->findAll();
49✔
522

523
        foreach ($rows as $row) {
49✔
524
            $this->groups[$row->id] = $row->name;
49✔
525
        }
526

527
        $this->groupCache = array_column($this->getAllUserGroups(), 'name');
49✔
528

529
        // Eager-load all group permissions in a single pass (eliminates N+1 queries in can())
530
        $this->groupPermissionsCache = [];
49✔
531

532
        if ($this->groupCache !== []) {
49✔
533
            $this->eagerLoadGroupPermissions();
24✔
534
        }
535

536
        // Store in persistent cache
537
        if (service('settings')->get('AuthSecurity.permissionCacheEnabled')) {
49✔
538
            $ttl = (int) (service('settings')->get('AuthSecurity.permissionCacheTTL') ?? 300);
5✔
539
            cache()->save($this->getPermissionCacheKey('groups'), [
5✔
540
                'groups'                => $this->groups,
5✔
541
                'groupCache'            => $this->groupCache,
5✔
542
                'groupPermissionsCache' => $this->groupPermissionsCache,
5✔
543
            ], $ttl);
5✔
544
        }
545
    }
546

547
    /**
548
     * Used internally to populate the User permissions
549
     * so we hit the database as little as possible.
550
     * Reads from persistent cache when permissionCacheEnabled is true.
551
     */
552
    private function populatePermissions(): void
30✔
553
    {
554
        if (is_array($this->permissionsCache) && is_array($this->permissions)) {
30✔
555
            return;
20✔
556
        }
557

558
        // Try persistent cache first
559
        if (service('settings')->get('AuthSecurity.permissionCacheEnabled')) {
30✔
560
            /** @var array{permissions: array<int, string>, permissionsCache: list<string>}|null $cached */
561
            $cached = cache($this->getPermissionCacheKey('permissions'));
4✔
562

563
            if ($cached !== null) {
4✔
UNCOV
564
                $this->permissions      = $cached['permissions'];
×
UNCOV
565
                $this->permissionsCache = $cached['permissionsCache'];
×
566

UNCOV
567
                return;
×
568
            }
569
        }
570

571
        $permissionModel = $this->permissionModel();
30✔
572
        $rows            = $permissionModel->findAll();
30✔
573

574
        foreach ($rows as $row) {
30✔
575
            $this->permissions[$row->id] = $row->name;
26✔
576
        }
577

578
        $this->permissionsCache = array_column($this->getAllUserPermissions(), 'name');
30✔
579

580
        // Store in persistent cache
581
        if (service('settings')->get('AuthSecurity.permissionCacheEnabled')) {
30✔
582
            $ttl = (int) (service('settings')->get('AuthSecurity.permissionCacheTTL') ?? 300);
4✔
583
            cache()->save($this->getPermissionCacheKey('permissions'), [
4✔
584
                'permissions'      => $this->permissions,
4✔
585
                'permissionsCache' => $this->permissionsCache,
4✔
586
            ], $ttl);
4✔
587
        }
588
    }
589

590
    /**
591
     * Returns the cache key for this user's permission/group cache entry.
592
     *
593
     * @param string $type 'groups' or 'permissions'
594
     */
595
    private function getPermissionCacheKey(string $type): string
8✔
596
    {
597
        return 'auth_' . $type . '_' . $this->id;
8✔
598
    }
599

600
    /**
601
     * Deletes the persistent cache entries for this user's groups and permissions.
602
     * Call this when group/permission assignments change.
603
     */
604
    public function clearPermissionCache(): void
5✔
605
    {
606
        cache()->delete($this->getPermissionCacheKey('groups'));
5✔
607
        cache()->delete($this->getPermissionCacheKey('permissions'));
5✔
608

609
        $this->groupPermissionsCache = null;
5✔
610
    }
611

612
    /**
613
     * Inserts or Updates the current groups.
614
     */
615
    private function saveGroups(): void
29✔
616
    {
617
        $model = $this->groupUserModel();
29✔
618

619
        $names = $this->groupCache;
29✔
620

621
        $cache = [];
29✔
622

623
        foreach ($names as $name) {
29✔
624
            $cache[] = array_search($name, $this->groups, true);
27✔
625
        }
626

627
        $existing = array_column($this->getAllUserGroups(), 'id');
29✔
628

629
        $this->saveGroupsOrPermissions('group_id', $model, $cache, $existing);
29✔
630

631
        // Invalidate persistent cache after DB write
632
        if (service('settings')->get('AuthSecurity.permissionCacheEnabled')) {
29✔
633
            cache()->delete($this->getPermissionCacheKey('groups'));
4✔
634
        }
635

636
        // Force re-population of group permissions on next access
637
        $this->groupPermissionsCache = null;
29✔
638
    }
639

640
    /**
641
     * Inserts or Updates either the current permissions.
642
     */
643
    private function savePermissions(): void
17✔
644
    {
645
        $model = $this->permissionUserModel();
17✔
646

647
        $names = $this->permissionsCache;
17✔
648

649
        $cache = [];
17✔
650

651
        foreach ($names as $name) {
17✔
652
            $cache[] = array_search($name, $this->permissions, true);
14✔
653
        }
654

655
        $existing = array_column($this->getAllUserPermissions(), 'id');
17✔
656

657
        $this->saveGroupsOrPermissions('permission_id', $model, $cache, $existing);
17✔
658

659
        // Invalidate persistent cache after DB write
660
        if (service('settings')->get('AuthSecurity.permissionCacheEnabled')) {
17✔
661
            cache()->delete($this->getPermissionCacheKey('permissions'));
4✔
662
        }
663
    }
664

665
    /**
666
     * @param         GroupUserModel|PermissionUserModel $model
667
     * @phpstan-param 'group_id'|'permission_id'         $type
668
     */
669
    private function saveGroupsOrPermissions(string $type, $model, array $cache, array $existing): void
45✔
670
    {
671
        $db  = Database::connect();
45✔
672
        $new = array_diff($cache, $existing);
45✔
673

674
        $db->transStart();
45✔
675

676
        // Delete any not in the cache
677
        if ($cache !== []) {
45✔
678
            $model->deleteNotIn($this->id, $cache);
40✔
679
        }
680
        // Nothing in the cache? Then make sure
681
        // we delete all from this user
682
        else {
683
            $model->deleteAll($this->id);
9✔
684
        }
685

686
        // Insert new ones
687
        if ($new !== []) {
45✔
688
            $inserts = [];
38✔
689

690
            foreach ($new as $item) {
38✔
691
                $inserts[] = [
38✔
692
                    'user_id'    => $this->id,
38✔
693
                    $type        => $item,
38✔
694
                    'created_at' => Time::now()->format('Y-m-d H:i:s'),
38✔
695
                ];
38✔
696
            }
697

698
            $model->insertBatch($inserts);
38✔
699
        }
700

701
        $db->transComplete();
45✔
702
    }
703
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc