FlexPoint Docs
Engineering

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:

  1. Permission — does the user's role grant this capability (e.g. loans.update)?
  2. 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:

ModelTableBacks
LoanVisibilityloan_user$user->loans / Loan::visibleTo($user)
LeadVisibilitylead_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:

MethodPermission(s)Also canSee
viewAnyloans.view— (list; query is scoped instead)
viewloans.view
createloans.create
updateloans.update
deleteloans.delete
transitionloans.update | loans.submit | underwriting.decision
viewSync / synclos.view / los.sync
lockpricing.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.

  • Authentication — how a user becomes the User these checks run against.
  • Backend Conventions — the FormRequest → Policy → Action → Resource controller shape.

On this page