Warehouse Permission Groups — Design
Status: Approved for implementation planning Scope: Pilot module — Warehouse only (12 existing submodules)
Problem
.ai/01-architecture-decisions.md decision #2 deliberately deferred legacy's
fine-grained RBAC system (~900 module.entity.action.scope keys) for this
migration, keeping only TeamRole (Owner/Admin/Member) plus one narrow,
later-added exception: sidebar-level module visibility
(App\Support\Modules, .ai/04-modules-and-permissions.md). There is no
mechanism today to control visibility per submodule, and no admin UI at
all for managing who can see what — legacy's own Settings\Permissions\ PermissionsController route is present in routes/web/octo-settings.php
only as a commented-out, confirmed-dead line.
The user wants to revive the legacy "Settings > Permissions" experience: named groups, member assignment, and permission toggles — starting with Warehouse as the pilot module before any wider rollout. This is a deliberate, explicit decision to begin executing the "full rebuild" that decision #2 parked, not an extension of the existing narrow exception.
Explicitly out of scope for this pilot, by the user's own instruction: per-action/entity permissioning (e.g. "can create but not delete a Gate Pass"). This pilot grants/denies at the submodule level only ("can see Gate Passes at all"). The user has said the very next phase after this pilot should extend it to action-level — this document's Future Work section records that so it isn't lost, but no action-level work is designed or built here.
Background: the 12 Warehouse submodules
Seeded in database/seeders/ModulesSeeder.php (module id 10 = warehouse),
these are the pilot's actual scope — not to be confused with the 13
menu => false placeholder ModuleSub rows (ids 1013-1025) added during the
Phase 1 ERP gap-analysis pass, which are unbuilt future tracks, not existing
UI:
| ModuleSub id | key | Route group |
|---|---|---|
| 1001 | stocks |
(none — no distinct web page; see Enforcement) |
| 1002 | grns |
warehouse.grns.* |
| 1003 | issues |
warehouse.gins.* |
| 1004 | transfers |
warehouse.transfers.* |
| 1005 | returns |
warehouse.returns.* |
| 1006 | gatepasses |
warehouse.gate-passes.* |
| 1007 | weigh-slips |
warehouse.weigh-slips.* |
| 1008 | audits |
warehouse.audits.* |
| 1009 | warehouses |
warehouse.stores.* (+ stores.locations.*) |
| 1010 | transfer-requests |
warehouse.transfer-requests.* |
| 1011 | pick-lists |
warehouse.pick-lists.* |
| 1012 | material-requests |
warehouse.material-requests.* |
Data model
Reuse the existing roles table (App\Models\System\Role) rather than add
new tables — it already has every field this needs
(name/users/permissions/status, tenant-scoped via
BelongsToOrganization), currently used only for type=1 per-user
overrides (App\Support\Modules::userPermissions()).
Add App\Enums\RoleType:
enum RoleType: int
{
case PerUser = 1;
case Group = 2;
}
A permission group is a roles row shaped:
type=RoleType::Group->value(2)typeID=0— sentinel. ThetypeIDcolumn isintegerand not nullable (database/migrations/2017_07_31_093847_create_permissions.php), with no natural single-user id for a multi-member group.0is used rather than a migration to make the column nullable.name— the group's display name, e.g. "Warehouse Supervisors"users— JSON array of memberUserids:[12, 45, 88]permissions— JSON shaped{"moduleSubs": {"grns": true, "gatepasses": false, ...}}, keyed byModuleSub.key(per.ai/04-modules-and-permissions.md's explicit instruction that the eventual rebuild reuse these keys as the permission namespace). Only the 12 Warehouse keys from the table above are populated by this pilot's UI; the shape itself is not Warehouse-specific, so a later module's rollout adds more keys under the samemoduleSubsobject without a schema change.status— active/inactive (disable a group without deleting it, and without losing its member list / grants)directPermissions/useDirectPermissions— legacy fields, untouched by this feature (left at column defaults)
No migration is needed. directPermissions/useDirectPermissions are not
repurposed — leaving them alone avoids any ambiguity with a future
action-level phase that might have real use for them.
Resolution logic
Extend App\Support\Modules with submodule-level resolution, following the
exact same override pattern the file already uses for modules
(team-level default, per-user override on top):
/**
* The team's active permission groups (type=2 `roles` rows), or an empty
* collection if none exist yet — fetch once per request and reuse across
* every submodule check.
*
* @return \Illuminate\Support\Collection<int, Role>
*/
public static function groupsForTeam(?Team $team): Collection
/**
* Whether the given ModuleSub key should be visible to this user.
*
* - No group in the team has an opinion on this key (key absent from
* every group's `permissions.moduleSubs`) -> true (ungated submodule,
* unaffected by this feature — every non-Warehouse submodule today).
* - The user belongs to zero of the team's groups that have an opinion on
* this key -> true (default-allow; nobody's sidebar breaks the day this
* ships, before an admin has assigned anyone to a group).
* - The user belongs to one or more such groups -> true if at least one
* of them grants the key, false otherwise.
*/
public static function isSubmoduleVisibleForUser(Collection $groups, User $user, string $subModuleKey): bool
The existing per-user type=1 override in userOverride()/
isVisibleForUser() is untouched — this is a new, additive check that sits
alongside it, not a replacement.
Enforcement
Two points, matching the existing module-level pattern plus the doc's explicit instruction to use Gates rather than ad-hoc controller checks:
1. Sidebar — HandleInertiaRequests::resolveModules() already filters
each Module's subModules relation to menu = true, status = true. Add
the new isSubmoduleVisibleForUser() check into that same map()/filter()
step for the warehouse module's subModules.
2. Routes — Gate::define('access-submodule', fn (User $user, string $subModuleKey) => ...)
registered in AppServiceProvider, backed by the same resolution method.
Applied as ->middleware('can:access-submodule,<key>') per resource group
in routes/web/warehouse.php, e.g.:
Route::middleware('can:access-submodule,grns')->group(function () {
Route::get('grns/{grn}/print', [GrnController::class, 'print'])->name('grns.print');
Route::resource('grns', GrnController::class)->only(['index', 'create', 'show']);
});
stocks (1001) has no distinct route group today (folded into the API
Stock\StockController, per the existing comment in warehouse.php) — it
is enforced at the sidebar only, same as it is today; there is no separate
page to gate.
Direct navigation or an API call to a gated submodule a user's groups deny now 403s, not just disappears from the menu.
Admin UI — Settings > Permissions
New controller App\Http\Controllers\Settings\Permissions\PermissionGroupController,
routed under the existing routes/web/octo-settings.php settings. group
(replacing the commented-out dead permissions resource line), admin-only
— matching the file's existing users. prefix convention
(EnsureTeamMembership::class.':admin'):
Route::prefix('permissions')->name('permissions.')->group(function () {
Route::middleware(EnsureTeamMembership::class.':admin')->group(function () {
Route::get('/', [PermissionGroupController::class, 'index'])->name('index');
Route::get('create', [PermissionGroupController::class, 'create'])->name('create');
Route::post('/', [PermissionGroupController::class, 'store'])->name('store');
Route::get('{role}/edit', [PermissionGroupController::class, 'edit'])->name('edit');
Route::put('{role}', [PermissionGroupController::class, 'update'])->name('update');
Route::delete('{role}', [PermissionGroupController::class, 'destroy'])->name('destroy');
});
});
Pages (resources/js/pages/settings/permissions/{Index,Create,Edit}.vue):
- Index — table of groups: name, member count, active/inactive, edit/delete actions.
- Create/Edit — group name, a member picker (multi-select over the
team's users, matching the existing team member list used elsewhere in
Settings), and a checkbox list of the 12 Warehouse submodules from the
table above (checked = granted). Saves to the
permissions.moduleSubsshape described above.
No sidebar entry for "Permissions" is added to Warehouse's own module —
this lives under the existing Settings area, alongside settings/modules/.
Testing
Feature tests (PHPUnit, following the project's no-factories convention):
PermissionGroupControllerTest— create/edit/list/delete a group; non-admin gets 403; a group'susers/permissionsround-trip correctly.ModulesSubmoduleVisibilityTest(or added to the existingApp\Support\Modulestest coverage) — the three resolution cases: no group has an opinion (visible), user in zero opinionated groups (visible), user in a denying group only (hidden), user in ≥1 granting group (visible) even if also in a denying one ("any grants" wins).- A route-level test per gated resource (or one parameterized test over the
table above) confirming
can:access-submodule,<key>denies a user whose groups deny that key and admits one whose groups grant it, plus a user in no groups (default-allow).
Future work (explicitly out of scope here)
The user has confirmed the next phase after this pilot should extend
grants to action-level (create/edit/delete/view, matching legacy's
module.entity.action.scope shape) rather than submodule-level only. That
work is not designed here — flagged so it is not lost, and left to its own
brainstorming pass when picked up, since it reopens the exact boundary
.ai/01-architecture-decisions.md decision #2 draws and needs its own
scope conversation (e.g. whether it stays JSON-shaped on roles or needs
the dedicated-tables approach this pilot deliberately avoided).
Files touched
- Create:
app/Enums/RoleType.php - Modify:
app/Support/Modules.php(add group resolution methods) - Modify:
app/Http/Middleware/HandleInertiaRequests.php(resolveModules()) - Modify:
app/Providers/AppServiceProvider.php(register the Gate) - Modify:
routes/web/warehouse.php(wrap each resource group with the Gate middleware) - Modify:
routes/web/octo-settings.php(uncomment/replace the deadpermissionsline) - Create:
app/Http/Controllers/Settings/Permissions/PermissionGroupController.php - Create:
resources/js/pages/settings/permissions/Index.vue - Create:
resources/js/pages/settings/permissions/Create.vue - Create:
resources/js/pages/settings/permissions/Edit.vue - Test:
tests/Feature/Settings/Permissions/PermissionGroupControllerTest.php - Test:
tests/Feature/Support/ModulesSubmoduleVisibilityTest.php(or extend existing Modules test file) - Test:
tests/Feature/Warehouse/SubmoduleAccessGateTest.php