Authorization & Visibility
The two independent gates every request passes — permission (what you can do) and data-scoped visibility (which records you can see) — and why even admins hold no visibility bypass.
Authorization in the portal is two independent checks, and an action is allowed only when both pass:
- Permission — does the user's role grant this capability (e.g.
loans.update)? - Visibility — is this specific record inside the set the user is allowed to see?
A permission alone never grants access to a record. Every instance policy ANDs a permission check with a per-user visibility check, and every list query is scoped to the same visible set.
Permission-only is a bug
A policy method that checks hasPermission(...) without also calling
canSee(...) is a data-scoped authorization regression: it would let
a user act on records outside their book. This is a review blocker — see the
Laravel review rules.
Layer 1 — Permissions (RBAC)
Roles and permissions are defined in App\Support\PermissionCatalog and seeded
(idempotently) during provisioning.
A user's granted keys come from their roles:
public function hasPermission(string $permissionKey): bool
{
return $this->permissionKeys()->contains($permissionKey);
}Dotted permission abilities (loans.view, los.sync, …) are resolved centrally
by a Gate::before hook in AuthServiceProvider:
Gate::before(function (User $user, string $ability, array $arguments = []): ?bool {
if (self::targetsImmutableInternalUser($ability, $arguments)) {
return null; // fall through to the denying policy
}
if (str_contains($ability, '.')) {
return $user->hasPermission($ability) ? true : null;
}
return null; // object-ability checks (view/update/...) run the Policy
});Returning null on a permission miss lets the object-level policy method
still run — which is where visibility is enforced.
Super admins
A super admin's permissionKeys() returns every key in the catalog, so they
pass Layer 1 for any capability. But — critically — that is not a visibility
bypass (see below).
Internal users are immutable
targetsImmutableInternalUser() stops the super-admin bypass for
mutating an InternalUser (create/update/restore/forceDelete/assignRoles).
Internal users are an MLM mirror; no portal write is permitted, not even for a
super admin — the request falls through to the denying InternalUserPolicy.
Layer 2 — Data-scoped visibility
Which loans/leads a user can see is materialized as pivot rows, one per (user, record) grant:
| Model | Table | Backs |
|---|---|---|
LoanVisibility | loan_user | $user->loans / Loan::visibleTo($user) |
LeadVisibility | lead_user | $user->leads / Lead::visibleTo($user) |
Each row carries the stable FK (loan_id / lead_id), the vendor number, an
optional reference-number bridge, and the report_name that produced the grant.
The FK is enforced on create — LoanVisibility::booted() resolves it from
vendor_loan_number when omitted.
The user↔record relationship is the authorization boundary in relationship form:
public function loans(): BelongsToMany
{
return $this->belongsToMany(Loan::class, 'loan_user', 'user_id', 'loan_id')
->withPivot(['report_name', 'loan_number', 'last_synced_at'])
->withTimestamps();
}The visibility scope
Loan::scopeVisibleTo() is the single authoritative predicate — a whereExists
against the pivot's indexed loan_id:
public function scopeVisibleTo(Builder $query, User $user): Builder
{
$loans = $this->getTable();
$pivot = (new LoanVisibility)->getTable();
return $query->whereExists(function ($sub) use ($user, $loans, $pivot): void {
$sub->selectRaw('1')
->from($pivot)
->where("{$pivot}.user_id", $user->id)
->whereColumn("{$pivot}.loan_id", "{$loans}.id");
});
}No one bypasses visibility — not even admins
The scope has no admin short-circuit: "Every user — administrators included — sees only loans they hold a visibility row for; there is no bypass." A super admin sees the whole company only because their MLM report grants them a visibility row for every active loan — the breadth is data-driven, not a code exception. Permissions bypass; visibility never does.
Lead::scopeVisibleTo() is the exact mirror on lead_user.
Dashboard scope
The pipeline dashboard uses scopeVisibleActivePipeline(), which additionally
constrains to active-report rows (LoanReportCatalog::activeLoanReportNames())
and matches on the loan_id FK only — deliberately not the vendor-number
backstop, which can't use an index and once turned this aggregate into a 25s+
scan.
Policies wire the two layers together
Every instance policy method ANDs a permission with the shared canSee() helper:
public function update(User $user, Loan $loan): bool
{
return $user->hasPermission('loans.update') && $this->canSee($user, $loan);
}
private function canSee(User $user, Loan $loan): bool
{
return Loan::query()->whereKey($loan->getKey())->visibleTo($user)->exists();
}LoanPolicy capability map:
| Method | Permission(s) | Also canSee |
|---|---|---|
viewAny | loans.view | — (list; query is scoped instead) |
view | loans.view | ✓ |
create | loans.create | — |
update | loans.update | ✓ |
delete | loans.delete | ✓ |
transition | loans.update | loans.submit | underwriting.decision | ✓ |
viewSync / sync | los.view / los.sync | ✓ |
lock | pricing.lock | loans.update | ✓ |
LeadPolicy mirrors this. Its delete is intentionally gated on loans.create
(not the destructive loans.delete) and isAbandonable() — a lead with a
completed credit order can't be deleted, even by a super admin.
Index queries carry the same scope
Instance policies protect a single record; list endpoints must apply the scope
directly so they never leak rows. Pipeline controllers query through
visibleTo / visibleActivePipeline (or $user->loans) rather than hand-rolled
joins:
// correct — scoped
Loan::query()->visibleTo($request->user())->paginate();Prefer the relationship, not a raw join
Traverse $user->loans / Loan::visibleTo($user) instead
of writing DB::table('loan_user')->join(...). A raw join on an
authorization path bypasses the one audited predicate and is a review flag.
Cross-tenant behavior
A user requesting a record outside their visible set gets a policy denial (403), or a 404 where the bound-model query is itself scoped. Both the happy path and the cross-tenant denial should be covered by Pest tests for every owned-model route.
Maintaining & auditing the pivots
Visibility rows are written by report sync, lead→loan conversion, MISMO import, and the dedup re-point — every path populates the stable FK.
php artisan mlm:audit-visibility measures how many grants rely only on the
vendor-number/reference backstop versus the stable FK. A result of 0 grants
lost means the FK alone reproduces every grant and the OR-union backstop is
redundant against current data. See
Artisan Commands.
Related
- Authentication — how a user becomes the
Userthese checks run against. - Backend Conventions — the FormRequest → Policy → Action → Resource controller shape.
Authentication
How users sign in — MLM-backed credential checks, the dual Sanctum + Portal-JWT session, httpOnly cookies at the BFF, and the two-factor challenge.
Change of Circumstance
Architecture of the Change of Circumstance request — the typed details value object, the form-context read model, the submit/PDF flow, the documentation-only MeridianLink writes, and the binary PDF routes.