Permissions & Profiles
Noerd's authorization is layered in two stages:
- Profiles: every user carries exactly ONE profile per tenant (
users_tenants.profile_key). The profile is the BASELINE of what the user may do. Profiles are a fixed technical concept, hardcoded as theNoerd\Enums\Profileenum — there is no profiles table and no profile CRUD. - Authorization gates (optional): a project may define the
noerd.*gates to decide app access, per-object abilities and named action checks centrally. A defined gate decides alone and is expected to incorporate the profile baseline itself.
Profiles
Case (Noerd\Enums\Profile) | Stored key | Baseline without gates |
|---|---|---|
Profile::Admin | ADMIN | Tenant administration: /setup access, bypasses every permission check |
Profile::User | USER | Full access — new apps and objects are usable immediately |
Profile::ReadOnly | READ_ONLY | Like User, but restricted to reading (and opening apps) |
- The profiles are HARDCODED — nothing is seeded, nothing can be created, renamed or deleted. Display labels come from
Profile::label()(translated). - The profile is assigned per tenant on the user detail (Setup → Users). A user without a profile behaves like
User— a missing assignment must never lock an installation out. NoerdUser::isAdmin()is the tenant-scoped ADMIN check (ADMIN profile of the selected tenant, or the globalsuper_adminflag). It guards/setup(SetupMiddleware), the admin-only components (ComponentAccessGuard) and is the bypass every gate implementation applies first.
Registering additional profiles
A module may offer additional profiles through the ProfileRegistry (singleton) — the profile pickers (user detail, tenant-access display) render from it, so a registered profile becomes assignable without any core change:
use Noerd\Services\ProfileRegistry;
app(ProfileRegistry::class)->register('MY_PROFILE', fn(): string => __('My Profile'));register(string $key, string|Closure $label) accepts a translation key or a lazy label closure; options() returns key => translated label (built-ins first, then registered profiles in registration order) and label(string $key) a single label. A registered profile's SEMANTICS come from the authorization gates the module defines; the core's own baseline treats unknown keys like User. NoerdUser::currentProfileKey() exposes the raw stored key for such modules; currentProfile() resolves only the built-in enum cases.
Super admin (installation admin)
Profiles are assigned PER TENANT. The one thing above them is the super_admin flag on noerd_users: an installation admin who administers every tenant of the installation.
isAdmin()is true in every tenant — the profile baseline and every gate implementation bypass on it,/setupis reachable everywhere.- Sees and edits every tenant (Setup → Tenants) and every account (Setup → Users), including accounts that belong to no tenant yet, and may impersonate any of them.
- May ENTER every tenant: the tenant switcher lists all tenants of the installation and the per-request membership check accepts them.
NoerdUser::accessibleTenants()/canAccessTenant()are the single source for "may this user work in that tenant" — never compare against$user->tenantsdirectly for that question;administeredTenants()is the matching Setup scope (every tenant vs. the ADMIN-profile memberships). - Protected in the other direction: a tenant admin can never edit, impersonate or (by e-mail) capture a super admin account.
- Visible: the Profile column of the users list renders the
Super Adminbadge (profile_for_tenant), followed by the tenant profile as plain text. - Set and withdrawn ONLY on the console — the column is
$guarded, no screen writes it:php artisan noerd:super-admin {id|email}grants the flag,--revokewithdraws it (the last super admin of an installation only with--force).noerd:installmakes the first user of a fresh installation a super admin (noerd:make-admin-user --super-admin). - A navigation entry with
superAdmin: trueis hidden from everybody else (see Navigation). - The only one who assigns apps to a tenant and removes them (Setup → Apps). A tenant admin sees the apps of their tenant there and may reorder and hide them, nothing more; a tenant they create starts without apps, while a super admin's new tenant copies the apps of the selected one.
Abilities
Every check goes through Noerd\Helpers\AccessHelper:
| Helper | Gate | Meaning |
|---|---|---|
canAccessApp($appName) | noerd.access-app | Open an app (tiles, app bar, app-access:{app} middleware, config discovery) |
canReadObject($modelClass) | noerd.object-read | See the object's records (list rows, detail mount, CSV export) |
canWriteObject($modelClass) | noerd.object-write | Update existing records |
canCreateObject($modelClass) | noerd.object-create | Create new records ("New …" buttons, store() on a new record) |
canDeleteObject($modelClass) | noerd.object-delete | Delete records (delete button, bulk delete) |
canPerformAction($actionKey) | noerd.action | Perform a named action declared in code (see below) |
canUseApp(...$appNames) | — | Convenience for app-bound chrome: at least one of the apps is ASSIGNED to the selected tenant and canAccessApp() allows it. No tenant → false |
canPassGate($ability) | any | A host-defined gate or policy ability (the YAML policy: key of quick-menu buttons and dashboard widgets): a Gate::define()d ability is checked as such, anything else against the Tenant policy. Guests → false |
The gate names are constants on AccessHelper (APP_GATE, OBJECT_READ_GATE, OBJECT_WRITE_GATE, OBJECT_CREATE_GATE, OBJECT_DELETE_GATE, ACTION_GATE).
With NO gate defined the profile baseline applies: User/Admin (and no profile) → everything, ReadOnly → only canReadObject/canAccessApp. Guests are never restricted (config discovery, unauthenticated rendering). Null arguments (no model/app/action known) are always allowed.
Defining a gate (e.g. in a service provider's boot()) replaces the baseline for that ability — see Extension Registries for the closure contract (nullable user!) and examples. The gate user is always resolved from noerd's own guard (Gate::forUser(NoerdAuth::user())), never from the host's default guard. An extension may implement these gates to provide grant-based permissions on top of the profiles.
Detail/page components additionally expose canSaveObject(): the ability for the form's CURRENT state — create while the record has no $modelId yet, write once it has one. The save button, the save shortcut and the readonly field rendering key off it; store() and the generic WriteGuardHook enforce it server-side. Settings pages (tenant singletons) always treat saving as a write.
Enforcement map (all generic — never duplicate per module)
- Lists (
NoerdList): read denial yields zero rows (whereRaw('1 = 0')) plus the denied panel, blocks CSV export, and strips header actions; "New …" actions (action: listActionor aroute:action) are stripped without create, every other header action without write; thedeleteSelectedbulk action is stripped/blocked without delete. - Details/pages (
NoerdPage/NoerdDetail): read denial blocks the mount before any record data is loaded;store()/delete()are no-ops without the matching ability — enforced even for custom overrides by the globalWriteGuardHook; fields render readonly, save/delete buttons and keyboard shortcuts are hidden. - Apps:
AppAccessMiddleware, the app bar, the home tiles and the allowed-app config discovery all consultcanAccessApp(). - Admin-only components:
/setuproutes run throughSetupMiddleware(NoerdUser::isAdmin()); the two entry points that bypass it — the client-dispatchablenoerdModalevent and/noerd/component-page/{componentName}— re-assert admin access throughNoerd\Support\ComponentAccessGuard. A module that ships admin-only screens registers them withComponentAccessGuard::registerAdminComponents()(see Extension Registries). - Dashboard widgets and quick-menu buttons declare
app:(string) orapps:(list) in their YAML — an entry renders only when one of its apps is assigned to the tenant AND allowed (AccessHelper::canUseApp(), see Dashboard Widgets and Quick Menu). The quick-menu is tenant scoped, not app scoped: a button renders the same target no matter which app is selected, so a component behind it must never readTenantHelper::getSelectedApp(). NEVER hand-roll a per-module "tenant has app X" gate for this — such gates ignore the user's profile. On routes use theapp-access:{app}middleware (see Authentication).
Locked component surface
Some public properties of list/detail/page components decide WHICH model the generic query reads, WHICH class the object gates are checked against and WHICH YAML layout drives validation. They are established at mount — from the component's own declaration or the opener's arguments — and are mutated only server-side, so a client update to them is always tampering. The global Noerd\Support\LockedPropertiesHook vetoes that update path for every component using NoerdList or NoerdPage (and therefore NoerdDetail).
Rejected on the client update path:
| Property | What repointing it would achieve |
|---|---|
detailModel, listModel | Query an unscoped model through the generic list/detail |
objectPermissionModel | Have the object gates checked against a different class |
detailComponent, detailRoute | Swap the modal target of a row click |
showMoreComponent, showMoreRoute | Swap the "show more" target |
listConfigComponent, detailConfigComponent, listComponent, listEntity, selectEvent, deepLinkParam, showFromDateColumn, showUntilDateColumn | Repoint the naming hooks (which YAML renders, which event/list a save or selection targets) or the columns the ShowFrom/ShowUntil filters write into the query — declared protected by convention, vetoed here in case a component makes one public |
listActionMethod | Redirect the picker callback |
listView, listViewApp | Render another app's list view |
settingsModels | Repoint which model persistSettings() writes and which property it reads |
pageLayout | Skip validateFromLayout() and mount attacker-chosen nested components |
Additionally rejected as MOUNT ARGUMENTS — detailModel, listModel and objectPermissionModel. Livewire assigns mount parameters to matching public properties before any update hook runs, and the modal stack takes those arguments straight from the client; no legitimate caller passes them, because a component declares them itself. Properties like listActionMethod or context are deliberately not mount-protected — the relation-field picker passes them.
A violation throws Livewire's CannotUpdateLockedPropertyException. The practical rule: never bind any of these properties to wire:model and never pass the three mount-protected ones as modal arguments — set them as literal property defaults on the component instead.
Query-level read guard (opt-in trait)
The generic read guards cover the NoerdList/NoerdDetail render paths. Data that flows through HAND-BUILT queries — dashboard counters (Invoice::query()->count() / ->sum()), bespoke widgets, manual listData() queries, reports — is NOT guarded automatically: noerd promises to never silently rewrite an application's queries, and an automatic global guard would also empty framework internals (users, tenants) and break sign-in under a restrictive gate.
Protecting an object at the query level therefore REQUIRES an explicit opt-in on its model — without the trait it does not happen:
use Noerd\Traits\GuardedByObjectPermission;
class Invoice extends Model
{
use GuardedByObjectPermission;
}The trait registers a global scope: while canReadObject() denies the model for the current user, EVERY query on it yields nothing — aggregates, relations and raw counters included, so denied tiles and reports simply show no data. Admins, guests and every context without an authenticated noerd user (console, queue jobs, public shop pages) are unaffected. A system read that must run inside a denied user's request lifts the guard explicitly with Invoice::withoutGlobalScope(Invoice::OBJECT_READ_GUARD_SCOPE). Reference: GuardedByObjectPermission, ObjectReadGuardScopeTest.
Known boundaries
- Custom PUBLIC methods on components (
approve(),duplicate(), custom bulk actions) are NOT guarded generically — onlystore/deleterun through theWriteGuardHook. Guard such methods yourself (an object ability check or a named action check). - A list with a hand-built query in
listData()bypasseslistQuery()'s read-denial row filter — give the model theGuardedByObjectPermissiontrait (see above) or applyAccessHelper::canReadObject()yourself. - Hand-built aggregate queries (dashboard counters etc.) are only guarded for models that opt into
GuardedByObjectPermission. StaticConfigHelper::getListConfigForApp()deliberately resolves configs of every app (cross-app list views); the DATA behind them is still guarded by the object abilities.
Named action checks
A named action guards an operation that is not a plain CRUD ability — "start the production run", "merge accounts". Actions are declared in code and checked at their call sites; they are mutations, so the profile baseline treats them like writes (ReadOnly is denied).
1. Declare the action in the module ServiceProvider's boot() so authorization tooling can enumerate it. Keys are snake_case ([a-z0-9_]):
use Noerd\Services\ActionPermissionRegistry;
app(ActionPermissionRegistry::class)->register('production_start_run', 'Start Production Run');2. Check it — either on a route:
Route::post('production/start', StartRunController::class)
->middleware(['noerd', 'app-access:production', 'action-permission:production_start_run']);…or manually in a Livewire action / service:
use Noerd\Helpers\AccessHelper;
if (! AccessHelper::canPerformAction('production_start_run')) {
return;
}With a noerd.action gate defined, its implementation decides. Without one the baseline applies — regular users may perform every action. Always register what you check.