OCTO Ops Help User guides and product documentation

2026 08 30 Warehouse Permission Groups Design

On this page 9

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:

php
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. The typeID column is integer and not nullable (database/migrations/2017_07_31_093847_create_permissions.php), with no natural single-user id for a multi-member group. 0 is used rather than a migration to make the column nullable.
  • name — the group's display name, e.g. "Warehouse Supervisors"
  • users — JSON array of member User ids: [12, 45, 88]
  • permissions — JSON shaped {"moduleSubs": {"grns": true, "gatepasses": false, ...}}, keyed by ModuleSub.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 same moduleSubs object 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):

php
/**
 * 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. SidebarHandleInertiaRequests::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. RoutesGate::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.:

php
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'):

php
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.moduleSubs shape 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's users/permissions round-trip correctly.
  • ModulesSubmoduleVisibilityTest (or added to the existing App\Support\Modules test 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 dead permissions line)
  • 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