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

codeigniter4 / shield / 22001345287

13 Feb 2026 08:15PM UTC coverage: 92.812% (+0.02%) from 92.788%
22001345287

Pull #1309

github

web-flow
Merge 66a8970f0 into 903ae0137
Pull Request #1309: feat: Support hierarchical permissions in method

2905 of 3130 relevant lines covered (92.81%)

25.82 hits per line

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

96.25
/src/Models/UserModel.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter Shield.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
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 CodeIgniter\Shield\Models;
15

16
use CodeIgniter\Database\Exceptions\DataException;
17
use CodeIgniter\Database\RawSql;
18
use CodeIgniter\I18n\Time;
19
use CodeIgniter\Shield\Authentication\Authenticators\Session;
20
use CodeIgniter\Shield\Entities\User;
21
use CodeIgniter\Shield\Entities\UserIdentity;
22
use CodeIgniter\Shield\Exceptions\InvalidArgumentException;
23
use CodeIgniter\Shield\Exceptions\LogicException;
24
use CodeIgniter\Shield\Exceptions\ValidationException;
25
use Faker\Generator;
26

27
/**
28
 * @phpstan-consistent-constructor
29
 */
30
class UserModel extends BaseModel
31
{
32
    protected $primaryKey     = 'id';
33
    protected $returnType     = User::class;
34
    protected $useSoftDeletes = true;
35
    protected $allowedFields  = [
36
        'username',
37
        'status',
38
        'status_message',
39
        'active',
40
        'last_active',
41
    ];
42
    protected $useTimestamps = true;
43
    protected $afterFind     = ['fetchIdentities', 'fetchGroups', 'fetchPermissions'];
44
    protected $afterInsert   = ['saveEmailIdentity'];
45
    protected $afterUpdate   = ['saveEmailIdentity'];
46

47
    /**
48
     * Whether identity records should be included
49
     * when user records are fetched from the database.
50
     */
51
    protected bool $fetchIdentities = false;
52

53
    /**
54
     * Whether groups should be included
55
     * when user records are fetched from the database.
56
     */
57
    protected bool $fetchGroups = false;
58

59
    /**
60
     * Whether permissions should be included
61
     * when user records are fetched from the database.
62
     */
63
    protected bool $fetchPermissions = false;
64

65
    /**
66
     * Save the User for afterInsert and afterUpdate
67
     */
68
    protected ?User $tempUser = null;
69

70
    protected function initialize(): void
71
    {
72
        parent::initialize();
299✔
73

74
        $this->table = $this->tables['users'];
299✔
75
    }
76

77
    /**
78
     * Mark the next find* query to include identities
79
     *
80
     * @return $this
81
     */
82
    public function withIdentities(): self
83
    {
84
        $this->fetchIdentities = true;
5✔
85

86
        return $this;
5✔
87
    }
88

89
    /**
90
     * Mark the next find* query to include groups
91
     *
92
     * @return $this
93
     */
94
    public function withGroups(): self
95
    {
96
        $this->fetchGroups = true;
8✔
97

98
        return $this;
8✔
99
    }
100

101
    /**
102
     * Mark the next find* query to include permissions
103
     *
104
     * @return $this
105
     */
106
    public function withPermissions(): self
107
    {
108
        $this->fetchPermissions = true;
7✔
109

110
        return $this;
7✔
111
    }
112

113
    /**
114
     * Populates identities for all records
115
     * returned from a find* method. Called
116
     * automatically when $this->fetchIdentities == true
117
     *
118
     * Model event callback called by `afterFind`.
119
     */
120
    protected function fetchIdentities(array $data): array
121
    {
122
        if (! $this->fetchIdentities) {
259✔
123
            return $data;
259✔
124
        }
125

126
        $userIds = $data['singleton']
5✔
127
            ? array_column($data, 'id')
2✔
128
            : array_column($data['data'], 'id');
3✔
129

130
        if ($userIds === []) {
5✔
131
            return $data;
2✔
132
        }
133

134
        /** @var UserIdentityModel $identityModel */
135
        $identityModel = model(UserIdentityModel::class);
3✔
136

137
        // Get our identities for all users
138
        $identities = $identityModel->getIdentitiesByUserIds($userIds);
3✔
139

140
        if (empty($identities)) {
3✔
141
            return $data;
×
142
        }
143

144
        $mappedUsers = $this->assignIdentities($data, $identities);
3✔
145

146
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
3✔
147

148
        return $data;
3✔
149
    }
150

151
    /**
152
     * Map our users by ID to make assigning simpler
153
     *
154
     * @param array              $data       Event $data
155
     * @param list<UserIdentity> $identities
156
     *
157
     * @return         list<User>              UserId => User object
158
     * @phpstan-return array<int|string, User> UserId => User object
159
     */
160
    private function assignIdentities(array $data, array $identities): array
161
    {
162
        $mappedUsers    = [];
3✔
163
        $userIdentities = [];
3✔
164

165
        $users = $data['singleton'] ? [$data['data']] : $data['data'];
3✔
166

167
        foreach ($users as $user) {
3✔
168
            $mappedUsers[$user->id] = $user;
3✔
169
        }
170
        unset($users);
3✔
171

172
        // Now group the identities by user
173
        foreach ($identities as $identity) {
3✔
174
            $userIdentities[$identity->user_id][] = $identity;
3✔
175
        }
176
        unset($identities);
3✔
177

178
        // Now assign the identities to the user
179
        foreach ($userIdentities as $userId => $identityArray) {
3✔
180
            $mappedUsers[$userId]->identities = $identityArray;
3✔
181
        }
182
        unset($userIdentities);
3✔
183

184
        return $mappedUsers;
3✔
185
    }
186

187
    /**
188
     * Populates groups for all records
189
     * returned from a find* method. Called
190
     * automatically when $this->fetchGroups == true
191
     *
192
     * Model event callback called by `afterFind`.
193
     */
194
    protected function fetchGroups(array $data): array
195
    {
196
        if (! $this->fetchGroups) {
259✔
197
            return $data;
259✔
198
        }
199

200
        $userIds = $data['singleton']
8✔
201
            ? array_column($data, 'id')
5✔
202
            : array_column($data['data'], 'id');
3✔
203

204
        if ($userIds === []) {
8✔
205
            return $data;
2✔
206
        }
207

208
        /** @var GroupModel $groupModel */
209
        $groupModel = model(GroupModel::class);
6✔
210

211
        // Get our groups for all users
212
        $groups = $groupModel->getGroupsByUserIds($userIds);
6✔
213

214
        $mappedUsers = $this->assignProperties($data, $groups, 'groups');
6✔
215

216
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
6✔
217

218
        return $data;
6✔
219
    }
220

221
    /**
222
     * Populates permissions for all records
223
     * returned from a find* method. Called
224
     * automatically when $this->fetchPermissions == true
225
     *
226
     * Model event callback called by `afterFind`.
227
     */
228
    protected function fetchPermissions(array $data): array
229
    {
230
        if (! $this->fetchPermissions) {
259✔
231
            return $data;
259✔
232
        }
233

234
        $userIds = $data['singleton']
7✔
235
            ? array_column($data, 'id')
4✔
236
            : array_column($data['data'], 'id');
3✔
237

238
        if ($userIds === []) {
7✔
239
            return $data;
2✔
240
        }
241

242
        /** @var PermissionModel $permissionModel */
243
        $permissionModel = model(PermissionModel::class);
5✔
244

245
        $permissions = $permissionModel->getPermissionsByUserIds($userIds);
5✔
246

247
        $mappedUsers = $this->assignProperties($data, $permissions, 'permissions');
5✔
248

249
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
5✔
250

251
        return $data;
5✔
252
    }
253

254
    /**
255
     * Map our users by ID to make assigning simpler
256
     *
257
     * @param array       $data       Event $data
258
     * @param list<array> $properties
259
     * @param string      $type       One of: 'groups' or 'permissions'
260
     *
261
     * @return list<User> UserId => User object
262
     */
263
    private function assignProperties(array $data, array $properties, string $type): array
264
    {
265
        $mappedUsers = [];
8✔
266

267
        $users = $data['singleton'] ? [$data['data']] : $data['data'];
8✔
268

269
        foreach ($users as $user) {
8✔
270
            $mappedUsers[$user->id] = $user;
8✔
271
        }
272
        unset($users);
8✔
273

274
        // Build method name
275
        $method = 'set' . ucfirst($type) . 'Cache';
8✔
276

277
        // Assign properties to all users (empty array if no properties found)
278
        foreach ($mappedUsers as $userId => $user) {
8✔
279
            $propertyArray = $properties[$userId] ?? [];
8✔
280
            $user->{$method}($propertyArray);
8✔
281
        }
282
        unset($properties);
8✔
283

284
        return $mappedUsers;
8✔
285
    }
286

287
    /**
288
     * Adds a user to the default group.
289
     * Used during registration.
290
     */
291
    public function addToDefaultGroup(User $user): void
292
    {
293
        $defaultGroup = setting('AuthGroups.defaultGroup');
7✔
294

295
        /** @var GroupModel $groupModel */
296
        $groupModel = model(GroupModel::class);
7✔
297

298
        if (empty($defaultGroup) || ! $groupModel->isValidGroup($defaultGroup)) {
7✔
299
            throw new InvalidArgumentException(lang('Auth.unknownGroup', [$defaultGroup ?? '--not found--']));
×
300
        }
301

302
        $user->addGroup($defaultGroup);
7✔
303
    }
304

305
    public function fake(Generator &$faker): User
306
    {
307
        $this->checkReturnType();
252✔
308

309
        return new $this->returnType([
252✔
310
            'username' => $faker->unique()->userName(),
252✔
311
            'active'   => true,
252✔
312
        ]);
252✔
313
    }
314

315
    /**
316
     * Locates a User object by ID.
317
     *
318
     * @param int|string $id
319
     */
320
    public function findById($id): ?User
321
    {
322
        return $this->find($id);
89✔
323
    }
324

325
    /**
326
     * Locate a User object by the given credentials.
327
     *
328
     * @param array<string, string> $credentials
329
     */
330
    public function findByCredentials(array $credentials): ?User
331
    {
332
        // Email is stored in an identity so remove that here
333
        $email = $credentials['email'] ?? null;
48✔
334
        unset($credentials['email']);
48✔
335

336
        if ($email === null && $credentials === []) {
48✔
337
            return null;
1✔
338
        }
339

340
        // any of the credentials used should be case-insensitive
341
        foreach ($credentials as $key => $value) {
48✔
342
            $this->where(
3✔
343
                'LOWER(' . $this->db->protectIdentifiers($this->table . ".{$key}") . ')',
3✔
344
                strtolower($value),
3✔
345
            );
3✔
346
        }
347

348
        if ($email !== null) {
48✔
349
            /** @var array<string, int|string|null>|null $data */
350
            $data = $this->select(
45✔
351
                sprintf('%1$s.*, %2$s.secret as email, %2$s.secret2 as password_hash', $this->table, $this->tables['identities']),
45✔
352
            )
45✔
353
                ->join($this->tables['identities'], sprintf('%1$s.user_id = %2$s.id', $this->tables['identities'], $this->table))
45✔
354
                ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD)
45✔
355
                ->where(
45✔
356
                    'LOWER(' . $this->db->protectIdentifiers($this->tables['identities'] . '.secret') . ')',
45✔
357
                    strtolower($email),
45✔
358
                )
45✔
359
                ->asArray()
45✔
360
                ->first();
45✔
361

362
            if ($data === null) {
45✔
363
                return null;
11✔
364
            }
365

366
            $email = $data['email'];
34✔
367
            unset($data['email']);
34✔
368
            $password_hash = $data['password_hash'];
34✔
369
            unset($data['password_hash']);
34✔
370

371
            $this->checkReturnType();
34✔
372

373
            $user                = new $this->returnType($data);
34✔
374
            $user->email         = $email;
34✔
375
            $user->password_hash = $password_hash;
34✔
376
            $user->syncOriginal();
34✔
377

378
            return $user;
34✔
379
        }
380

381
        return $this->first();
3✔
382
    }
383

384
    /**
385
     * Activate a User.
386
     */
387
    public function activate(User $user): void
388
    {
389
        $user->active = true;
×
390

391
        $this->save($user);
×
392
    }
393

394
    /**
395
     * Override the BaseModel's `insert()` method.
396
     * If you pass User object, also inserts Email Identity.
397
     *
398
     * @param array|User $row
399
     *
400
     * @return int|string|true Insert ID if $returnID is true
401
     *
402
     * @throws ValidationException
403
     */
404
    public function insert($row = null, bool $returnID = true)
405
    {
406
        // Clone User object for not changing the passed object.
407
        $this->tempUser = $row instanceof User ? clone $row : null;
258✔
408

409
        $result = parent::insert($row, $returnID);
258✔
410

411
        $this->checkQueryReturn($result);
258✔
412

413
        return $returnID ? $this->insertID : $result;
258✔
414
    }
415

416
    /**
417
     * Override the BaseModel's `update()` method.
418
     * If you pass User object, also updates Email Identity.
419
     *
420
     * @param int|list<int|string>|RawSql|string|null $id
421
     * @param array|User                              $row
422
     *
423
     * @return true if the update is successful
424
     *
425
     * @throws ValidationException
426
     */
427
    public function update($id = null, $row = null): bool
428
    {
429
        // Clone User object for not changing the passed object.
430
        $this->tempUser = $row instanceof User ? clone $row : null;
30✔
431

432
        try {
433
            /** @throws DataException */
434
            $result = parent::update($id, $row);
30✔
435
        } catch (DataException $e) {
8✔
436
            // When $data is an array.
437
            if ($this->tempUser === null) {
8✔
438
                throw $e;
1✔
439
            }
440

441
            $messages = [
7✔
442
                lang('Database.emptyDataset', ['update']),
7✔
443
            ];
7✔
444

445
            if (in_array($e->getMessage(), $messages, true)) {
7✔
446
                $this->tempUser->saveEmailIdentity();
7✔
447

448
                return true;
7✔
449
            }
450

451
            throw $e;
×
452
        }
453

454
        $this->checkQueryReturn($result);
24✔
455

456
        return true;
24✔
457
    }
458

459
    /**
460
     * Override the BaseModel's `save()` method.
461
     * If you pass User object, also updates Email Identity.
462
     *
463
     * @param array|User $row
464
     *
465
     * @return true if the save is successful
466
     *
467
     * @throws ValidationException
468
     */
469
    public function save($row): bool
470
    {
471
        $result = parent::save($row);
37✔
472

473
        $this->checkQueryReturn($result);
36✔
474

475
        return true;
36✔
476
    }
477

478
    /**
479
     * Save Email Identity
480
     *
481
     * Model event callback called by `afterInsert` and `afterUpdate`.
482
     */
483
    protected function saveEmailIdentity(array $data): array
484
    {
485
        // If insert()/update() gets an array data, do nothing.
486
        if ($this->tempUser === null) {
258✔
487
            return $data;
10✔
488
        }
489

490
        // Insert
491
        if ($this->tempUser->id === null) {
257✔
492
            /** @var User $user */
493
            $user = $this->find($this->db->insertID());
250✔
494

495
            // If you get identity (email/password), the User object must have the id.
496
            $this->tempUser->id = $user->id;
250✔
497

498
            $user->email         = $this->tempUser->email ?? '';
250✔
499
            $user->password      = $this->tempUser->password ?? '';
250✔
500
            $user->password_hash = $this->tempUser->password_hash ?? '';
250✔
501

502
            $user->saveEmailIdentity();
250✔
503
            $this->tempUser = null;
250✔
504

505
            return $data;
250✔
506
        }
507

508
        // Update
509
        $this->tempUser->saveEmailIdentity();
26✔
510
        $this->tempUser = null;
26✔
511

512
        return $data;
26✔
513
    }
514

515
    /**
516
     * Updates the user's last active date.
517
     */
518
    public function updateActiveDate(User $user): void
519
    {
520
        assert($user->last_active instanceof Time);
521

522
        // Safe date string for database
523
        $last_active = $this->timeToDate($user->last_active);
13✔
524

525
        $this->builder()
13✔
526
            ->set('last_active', $last_active)
13✔
527
            ->where('id', $user->id)
13✔
528
            ->update();
13✔
529
    }
530

531
    private function checkReturnType(): void
532
    {
533
        if (! is_a($this->returnType, User::class, true)) {
261✔
534
            throw new LogicException('Return type must be a subclass of ' . User::class);
×
535
        }
536
    }
537

538
    /**
539
     * Returns a new User Entity.
540
     *
541
     * @param array<string, array<array-key, mixed>|bool|float|int|object|string|null> $data (Optional) user data
542
     */
543
    public function createNewUser(array $data = []): User
544
    {
545
        return new $this->returnType($data);
6✔
546
    }
547
}
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