Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ The Java/Spring rules in `.claude/rules/java/` apply with these Grails-specific

## Build & Run

Before running frontend commands (`npm test`, `npm run watch`, `npm run bundle`, or Gradle tasks that invoke frontend bundling), use Node 14 in your shell:

```bash
nvm use 14
```

```bash
# Backend
./gradlew bootRun
Expand Down
6 changes: 3 additions & 3 deletions grails-app/conf/runtime.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,10 @@ openboxes {
label: "",
defaultLabel: "Purchasing",
menuItems: [
[label: "order.createPurchase.label", defaultLabel: "Create Purchase Order", href: "/purchaseOrder/create", requiredActivitiesAny: [ActivityCode.PLACE_ORDER]],
[label: "order.createPurchase.label", defaultLabel: "Create Purchase Order", href: "/purchaseOrder/create", requiredActivitiesAny: [ActivityCode.PLACE_ORDER], minimumRequiredRole: RoleType.ROLE_ASSISTANT],
[label: "order.listPurchase.label", defaultLabel: "List Purchase Orders", href: "/purchaseOrder/list"],
[label: "location.listSuppliers.label", defaultLabel: "List Suppliers", href: "/supplier/list"],
[label: "shipment.shipfromPO.label", defaultLabel: "Ship from Purchase Order", href: "/stockMovement/createCombinedShipments?direction=INBOUND"],
[label: "shipment.shipfromPO.label", defaultLabel: "Ship from Purchase Order", href: "/stockMovement/createCombinedShipments?direction=INBOUND", minimumRequiredRole: RoleType.ROLE_ASSISTANT],
[label: "dashboard.supplierDashboard.label", defaultLabel: "Supplier Dashboard", href: "/dashboard/supplier"]
]
]
Expand Down Expand Up @@ -397,7 +397,7 @@ openboxes {
defaultLabel = "Stock Lists"
menuItems = [
[label: "requisitionTemplates.list.label", defaultLabel: "List stock lists", href: "/requisitionTemplate/list"],
[label: "requisitionTemplates.create.label", defaultLabel: "Create stock list", href: "/requisitionTemplate/create", minimumRequiredRole: RoleType.ROLE_ADMIN],
[label: "requisitionTemplates.create.label", defaultLabel: "Create stock list", href: "/requisitionTemplate/create", supplementalRoles: [RoleType.ROLE_SUPERUSER, RoleType.ROLE_ADMIN, RoleType.ROLE_REGIONAL_WAREHOUSE]],
]
}
configuration {
Expand Down
25 changes: 23 additions & 2 deletions grails-app/controllers/org/pih/warehouse/RoleInterceptor.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.pih.warehouse.core.RoleType
* */
class RoleInterceptor {
def userService
def customRolePolicyService

// this interceptor depends on SecurityInterceptor
int order = LOWEST_PRECEDENCE
Expand Down Expand Up @@ -105,6 +106,24 @@ class RoleInterceptor {
}

boolean before() {
if (session?.warehouse?.id) {
Map customRoleAccess = customRolePolicyService.evaluateRouteAccess(
session?.user,
session?.warehouse?.id,
controllerName,
actionName,
params,
request
)
if (customRoleAccess.denied) {
log.info("User ${session?.user?.username} does not have access to ${controllerName}/${actionName} in location ${session?.warehouse?.name}")
redirect(controller: "errors", action: "handleForbidden")
return false
}
if (customRoleAccess.allowed) {
return true
}
}

def rules = grailsApplication.config.openboxes.security.rbac.rules
def rule = rules.find { it.controller == controllerName && it.actions.contains(actionName) ||
Expand All @@ -128,7 +147,7 @@ class RoleInterceptor {

Boolean isUserInRole = true
if (session.user && supplementalRoles.size() > 0) {
isUserInRole = userService.isUserInRole(session.user, supplementalRoles)
isUserInRole = userService.hasAnyRoles(session.user, supplementalRoles)
}

if (isAnonymous || (session.user && isMinimumRequiredRole && isUserInRole)) {
Expand All @@ -148,7 +167,8 @@ class RoleInterceptor {
// Authorized users
def isNotAuthenticated = !userService.isUserInRole(session.user, RoleType.ROLE_AUTHENTICATED)
def isNotBrowser = !userService.canUserBrowse(session.user) && !needRequestorOrManager(controllerName, actionName)
def isNotManager = needManager(controllerName, actionName) && (needRequestorOrManager(controllerName, actionName) ? !userService.isUserManager(session.user) && !userService.isUserRequestor(session.user) : !userService.isUserManager(session.user))
def isNotManager = needManager(controllerName, actionName) &&
(needRequestorOrManager(controllerName, actionName) ? !userService.isUserManager(session.user) && !userService.isUserRequestor(session.user) : !userService.isUserManager(session.user))
def isNotAdmin = needAdmin(controllerName, actionName) && !userService.isUserAdmin(session.user)
def isNotSuperuser = needSuperuser(controllerName, actionName) && !userService.isSuperuser(session.user)
def hasNoRoleInvoice = needInvoice(controllerName, actionName) && !userService.hasRoleInvoice(session.user)
Expand Down Expand Up @@ -202,4 +222,5 @@ class RoleInterceptor {
static Boolean needAuthenticatedActions(controllerName, actionName) {
authenticatedActions[controllerName]?.contains(actionName)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import java.text.SimpleDateFormat
class ApiController {

def userService
def customRolePolicyService
def helpScoutService
def localizationService
GrailsApplication grailsApplication
Expand Down Expand Up @@ -91,7 +92,9 @@ class ApiController {
Map menuSectionsUrlParts = grailsApplication.config.openboxes.menuSectionsUrlParts
User user = User.get(session?.user?.id)

if (userService.hasHighestRole(user, session?.warehouse?.id, RoleType.ROLE_AUTHENTICATED)) {
boolean hasCustomPolicy = customRolePolicyService.hasAnyCustomPolicy(user, session?.warehouse?.id)
if (userService.hasHighestRole(user, session?.warehouse?.id, RoleType.ROLE_AUTHENTICATED) &&
!hasCustomPolicy) {
menuConfig = grailsApplication.config.openboxes.requestorMegamenu;
}
List translatedMenu = megamenuService.buildAndTranslateMenu(menuConfig, user, location)
Expand Down Expand Up @@ -162,6 +165,7 @@ class ApiController {
// TODO: investigate why in isUserManager method in userService there is Assistant role included
ArrayList<RoleType> managerRoles = [RoleType.ROLE_SUPERUSER, RoleType.ROLE_ADMIN, RoleType.ROLE_MANAGER]
boolean isUserManager = userService.getEffectiveRoles(user).any { managerRoles.contains(it.roleType) }
Map<String, Object> customRolePermissions = customRolePolicyService.getCustomRolePermissions(session?.user, session.warehouse?.id)
def supportedActivities = location.supportedActivities ?: location.locationType.supportedActivities
boolean isImpersonated = session.impersonateUserId ? true : false
def buildNumber = gitProperties.shortCommitId
Expand Down Expand Up @@ -207,6 +211,7 @@ class ApiController {
isUserApprover : isUserApprover,
isUserRequestApprover : isUserRequestApprover,
isUserManager : isUserManager,
customRolePermissions : customRolePermissions,
supportedActivities : supportedActivities,
isImpersonated : isImpersonated,
grailsVersion : grailsVersion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,12 @@ class LocationApiController extends BaseDomainApiController {
def isRequestor = userService.isUserRequestor(currentUser)
def requestorInAnyLocation = userService.hasRoleRequestorInAnyLocations(currentUser)
def inRoleBrowser = currentUser.hasDefaultRole(RoleType.ROLE_BROWSER)
def inRoleAssistant = currentUser.hasDefaultRole(RoleType.ROLE_ASSISTANT)
def inRoleManager = currentUser.hasDefaultRole(RoleType.ROLE_MANAGER)
def inRoleAdmin = currentUser.hasDefaultRole(RoleType.ROLE_ADMIN)
def inRoleSuperuser = currentUser.hasDefaultRole(RoleType.ROLE_SUPERUSER)


def requiredRoles = RoleType.listRoleTypesForLocationChooser()
boolean hasDefaultLocationChooserRole = requiredRoles.any { roleType ->
currentUser.hasDefaultRole(roleType)
}


if (params.locationChooser && isRequestor && !currentUser.locationRoles && !inRoleBrowser) {
Expand All @@ -81,7 +80,7 @@ class LocationApiController extends BaseDomainApiController {
locations += locationService.getRequestorLocations(currentUser)
}
// If a user doesn't have at least one of the requiredRoles by default, get locations where the user HAS any of those roles
if (params.locationChooser && !inRoleBrowser && !inRoleAssistant && !inRoleManager && !inRoleAdmin && !inRoleSuperuser) {
if (params.locationChooser && !inRoleBrowser && !hasDefaultLocationChooserRole) {
currentUser.locationRoles.each { LocationRole locationRole ->
if (requiredRoles.contains(locationRole.role.roleType)) {
locations += locationRole.location
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class PurchaseOrderController {


def orderService
def userService

def index() {
redirect(action: "create")
Expand All @@ -29,6 +30,9 @@ class PurchaseOrderController {


def create() {
if (userService.hasFacilityStorekeeperPolicy(session.user, session?.warehouse?.id)) {
throw new UnsupportedOperationException("${warehouse.message(code: 'errors.noPermissions.label')}")
}
Location currentLocation = Location.get(session.warehouse.id)
User user = User.get(session.user.id)
if (!currentLocation.supports(ActivityCode.PLACE_ORDER)) {
Expand Down Expand Up @@ -65,6 +69,9 @@ class PurchaseOrderController {
return
}
Order order = params.order?.id ? Order.get(params.order.id) : new Order()
if (userService.hasFacilityStorekeeperPolicy(session.user, session?.warehouse?.id) && !order?.id) {
throw new UnsupportedOperationException("${warehouse.message(code: 'errors.noPermissions.label')}")
}

if (order.orderItems && order.origin.id != params.origin.id) {
order.errors.reject("purchaseOrder.supplierError.label", "Cannot change the supplier for a PO with item lines.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ class DashboardController {
redirect(controller: "mobile")
return
}
if (userService.hasHighestRole(session?.user, session?.warehouse?.id, RoleType.ROLE_AUTHENTICATED)) {
boolean hasCustomPolicy = userService.hasAnyCustomPolicy(session?.user, session?.warehouse?.id)
if (userService.hasHighestRole(session?.user, session?.warehouse?.id, RoleType.ROLE_AUTHENTICATED) &&
!hasCustomPolicy) {
redirect(controller: "stockMovement", action: "list", params: [direction: 'INBOUND'] )
return
}
Expand Down Expand Up @@ -170,7 +172,9 @@ class DashboardController {
Map menuConfig = grailsApplication.config.openboxes.megamenu;
User user = User.get(session?.user?.id)

if (userService.hasHighestRole(user, session?.warehouse?.id, RoleType.ROLE_AUTHENTICATED)) {
boolean hasCustomPolicy = userService.hasAnyCustomPolicy(user, session?.warehouse?.id)
if (userService.hasHighestRole(user, session?.warehouse?.id, RoleType.ROLE_AUTHENTICATED) &&
!hasCustomPolicy) {
menuConfig = grailsApplication.config.openboxes.requestorMegamenu;
}
List translatedMenu = megamenuService.buildAndTranslateMenu(menuConfig, user, location)
Expand Down Expand Up @@ -227,7 +231,9 @@ class DashboardController {
session.user = user
}

if (userService.hasHighestRole(session?.user, session?.warehouse?.id, RoleType.ROLE_AUTHENTICATED)) {
boolean hasCustomPolicy = userService.hasAnyCustomPolicy(session?.user, session?.warehouse?.id)
if (userService.hasHighestRole(session?.user, session?.warehouse?.id, RoleType.ROLE_AUTHENTICATED) &&
!hasCustomPolicy) {
redirect(controller: 'stockMovement', action: 'list' , params: [direction: 'INBOUND'])
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ class UserController {
}
}


// Unrelated to role customizations: pre-existing fix for activation toggle flush without transaction.
@Transactional
def toggleActivation() {
def userInstance = User.get(params.id)
if (!userInstance) {
Expand Down
10 changes: 6 additions & 4 deletions grails-app/domain/org/pih/warehouse/core/User.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,18 @@ class User extends Person {
}

boolean hasPrimaryRole(Location currentLocation) {
def roles = getEffectiveRoles(currentLocation)
return roles.roleType.find { RoleType.listPrimaryRoleTypes().contains(it) }
def roleNames = getEffectiveRoles(currentLocation)*.roleType*.name().findAll { it } as Set<String>
def primaryRoleNames = RoleType.listPrimaryRoleTypes()*.name() as Set<String>
return roleNames.any { primaryRoleNames.contains(it) }
}

/**
* @return does user have exact roles, either specified as global or location based
*/
boolean hasRoles(Location location, List<RoleType> roleTypes) {
List userRoles = getEffectiveRoles(location)?.collect { it.roleType }
return roleTypes?.every { userRoles.contains(it) }
Set<String> userRoleNames = (getEffectiveRoles(location)?.collect { it.roleType?.name() }?.findAll { it } ?: []) as Set<String>
Set<String> requiredRoleNames = (roleTypes?.collect { it?.name() }?.findAll { it } ?: []) as Set<String>
return requiredRoleNames.every { userRoleNames.contains(it) }
}

/**
Expand Down
4 changes: 4 additions & 0 deletions grails-app/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,10 @@ enum.RoleType.ROLE_ADMIN=Administrator
enum.RoleType.ROLE_MANAGER=Manager
enum.RoleType.ROLE_ASSISTANT=Assistant
enum.RoleType.ROLE_BROWSER=Browser
enum.RoleType.ROLE_FACILITY_STOREKEEPER=Facility Storekeeper
enum.RoleType.ROLE_REGIONAL_WAREHOUSE=Regional Warehouse User
enum.RoleType.ROLE_RPC_SUPERUSER=RPC Superuser
enum.RoleType.ROLE_REPORTING_USER=Reporting User
enum.RoleType.ROLE_PURCHASE_APPROVER=Purchase approver
enum.RoleType.ROLE_REQUISITION_APPROVER=Request approver
# Event messages
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
databaseChangeLog = {
changeSet(author: "codex", id: "202605151200-0") {
preConditions(onFail: "MARK_RAN") {
sqlCheck(expectedResult: "0", "select count(*) from role where role_type = 'ROLE_FACILITY_STOREKEEPER'")
}

insert(tableName: "role") {
column(name: "id", value: "ROLE_FACILITY_STOREKEEPER")
column(name: "version", valueNumeric: "0")
column(name: "description", value: "Role for facility storekeepers with inventory-change access and restricted purchasing/shipment creation access")
column(name: "role_type", value: "ROLE_FACILITY_STOREKEEPER")
column(name: "name", value: "Facility Storekeeper")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
databaseChangeLog = {
changeSet(author: "codex", id: "202605191200-0") {
preConditions(onFail: "MARK_RAN") {
sqlCheck(expectedResult: "0", "select count(*) from role where role_type = 'ROLE_REGIONAL_WAREHOUSE'")
}

insert(tableName: "role") {
column(name: "id", value: "ROLE_REGIONAL_WAREHOUSE")
column(name: "version", valueNumeric: "0")
column(name: "description", value: "Role for regional warehouse users with inventory/inbound/outbound operations, no purchasing access, and no inbound movement creation")
column(name: "role_type", value: "ROLE_REGIONAL_WAREHOUSE")
column(name: "name", value: "Regional Warehouse User")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
databaseChangeLog = {
changeSet(author: "codex", id: "202605201400-0") {
preConditions(onFail: "MARK_RAN") {
sqlCheck(expectedResult: "0", "select count(*) from role where role_type = 'ROLE_REPORTING_USER'")
}

insert(tableName: "role") {
column(name: "id", value: "ROLE_REPORTING_USER")
column(name: "version", valueNumeric: "0")
column(name: "description", value: "Role for reporting users with dashboard/reporting and read-only access to inventory, inbound, outbound, products, and stocklists")
column(name: "role_type", value: "ROLE_REPORTING_USER")
column(name: "name", value: "Reporting User")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
databaseChangeLog = {
changeSet(author: "codex", id: "202605201200-0") {
preConditions(onFail: "MARK_RAN") {
sqlCheck(expectedResult: "0", "select count(*) from role where role_type = 'ROLE_RPC_SUPERUSER'")
}

insert(tableName: "role") {
column(name: "id", value: "ROLE_RPC_SUPERUSER")
column(name: "version", valueNumeric: "0")
column(name: "description", value: "Role for RPC superusers with dashboard read and read/write access to inventory, purchasing, inbound, outbound, products, and stocklists")
column(name: "role_type", value: "ROLE_RPC_SUPERUSER")
column(name: "name", value: "RPC Superuser")
}
}
}
7 changes: 5 additions & 2 deletions grails-app/migrations/custom/changelog.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
*
* 1. Drop a file under grails-app/migrations/custom/<yyyy-mm-dd>-<feature>.groovy
* with a `databaseChangeLog = { changeSet(...) { ... rollback { ... } } }` block.
* 2. Append a one-line `include file: '<yyyy-mm-dd>-<feature>.groovy'` below.
* 2. Append a one-line `include file: 'custom/<yyyy-mm-dd>-<feature>.groovy'` below.
*
* Order include lines by FK dependency (target tables above holder tables).
* See .claude/rules/custom-package-isolation.md for the full rules.
*/
databaseChangeLog = {
// No custom migrations yet β€” append `include file:` lines here as they ship.
include file: 'custom/2026-05-15-add-facility-storekeeper-role.groovy'
include file: 'custom/2026-05-19-add-regional-warehouse-role.groovy'
include file: 'custom/2026-05-20-add-rpc-superuser-role.groovy'
include file: 'custom/2026-05-20-add-reporting-user-role.groovy'
}
Loading