Permission Matrices — Implementation Guide
This file covers everything needed to work with the permission matrix system in code. For the business rules and default values, see docu/docs/domain/permissions-matrix.md.
Architecture overview
Two DB-backed matrices replace all hardcoded hasUserAnyOrgRole / hasUserAnyWorkspaceRole write guards:
| Matrix | Entity | Service | Cache key |
|---|---|---|---|
| System (org-level) | OrgSystemPermission | OrgPermissionMatrixService | orgSystemPermissions |
| Application (workspace-level) | WorkspaceAppPermission | WorkspacePermissionMatrixService | workspaceAppPermissions |
Both are seeded with defaults on org/workspace creation and cached for 5 minutes (Caffeine).
Backend
Package location
com.axvero.ams.core.permission/
domain/
SystemPermission.java (enum, 19 values)
ApplicationPermission.java (enum, 9 values)
OrgSystemPermission.java (entity)
WorkspaceAppPermission.java (entity)
OrgSystemPermissionRepository.java
WorkspaceAppPermissionRepository.java
OrgPermissionMatrixService.java
WorkspacePermissionMatrixService.java
web/
PermissionMatrixController.java
PermissionMatrixMapper.java
dto/ (6 record classes)
Entities
Both entities are plain JPA entities — they do NOT extend BaseEntity (no slug, no entityType, no joined inheritance table needed).
@Entity @Table(name = "org_system_permission")
public class OrgSystemPermission {
@Id @GeneratedValue(strategy = GenerationType.UUID) UUID id;
UUID orgId;
String role; // MemberRole name e.g. "OWNER", "ADMIN"
@Enumerated(EnumType.STRING) SystemPermission permission;
}
WorkspaceAppPermission is identical with workspaceId instead of orgId and ApplicationPermission instead of SystemPermission.
Services
Both services share the same interface shape:
// Load all rows grouped by role. Returns empty set for unknown roles.
// Result is @Cacheable.
Map<String, Set<SystemPermission>> getMatrix(UUID orgId)
// Delete all rows for orgId, insert new ones. @CacheEvict after.
void setMatrix(UUID orgId, Map<String, Set<SystemPermission>> matrix)
// Primary check used by evaluator.
// SUPERADMIN → always true.
// Other roles → load matrix (cached), check entry.
boolean can(UUID orgId, String role, SystemPermission permission)
// Seed the default matrix (called once on org/workspace creation).
void seedDefaults(UUID orgId)
WorkspacePermissionMatrixService has the same shape with workspaceId and ApplicationPermission.
Evaluator methods
New methods added to AxveroRolePermissionEvaluator:
// Resolve user's org role, check system permission matrix
hasUserSystemPermission(UUID orgId, String permission)
// Resolve user's workspace role, check app permission matrix
hasUserApplicationPermission(UUID workspaceId, String permission)
// Resolver variants — look up entity → orgId/workspaceId, then check
hasUserSystemPermissionForTeam(UUID teamId, String permission)
hasUserSystemPermissionForClient(UUID clientId, String permission)
hasUserAppPermissionForApplication(UUID applicationId, String permission)
hasUserAppPermissionForApplicant(UUID applicantId, String permission)
hasUserAppPermissionForComment(UUID commentId, String permission)
Role resolution: picks the most privileged effective role (lowest MemberRole.ordinal()) from MemberRepository.findEffectiveOrgRoles / findEffectiveWorkspaceRoles.
@PreAuthorize patterns
Write operations use system/app permission checks. Read operations keep the old hasUserAnyWorkspaceRole / hasUserAnyOrgRole membership checks — they are not being replaced.
// Org-level write guard (have orgId directly)
@PreAuthorize("@axveroRolePermissionEvaluator.hasUserSystemPermission(#orgId, 'MANAGE_TEAMS')")
// Workspace-level write guard (resolve orgId from workspaceId via SpEL bean)
@PreAuthorize("@axveroRolePermissionEvaluator.hasUserSystemPermission(@workspaceRepository.findOrgIdByWorkspaceId(#workspaceId), 'MANAGE_APPLICATION_SETUP')")
// Resolver variant (no orgId parameter available)
@PreAuthorize("@axveroRolePermissionEvaluator.hasUserSystemPermissionForTeam(#teamId, 'MANAGE_TEAMS')")
// Application-level action
@PreAuthorize("@axveroRolePermissionEvaluator.hasUserAppPermissionForApplication(#applicationId, 'DECIDE')")
// Read guard — unchanged, still uses membership check
@PreAuthorize("@axveroRolePermissionEvaluator.hasUserAnyWorkspaceRole(#workspaceId, 'OWNER','ADMIN','MANAGER','MEMBER','CLIENT')")
The class-level @PreAuthorize("isAuthenticated()") on all controllers is always kept. Method-level annotations narrow it further.
GraphQL schema
# Queries
orgSystemPermissionMatrix(orgId: ID!): OrgSystemPermissionMatrix!
workspaceAppPermissionMatrix(workspaceId: ID!): WorkspaceAppPermissionMatrix!
# Mutations
updateOrgSystemPermissionMatrix(orgId: ID!, input: OrgSystemPermissionMatrixInput!): OrgSystemPermissionMatrix!
updateWorkspaceAppPermissionMatrix(workspaceId: ID!, input: WorkspaceAppPermissionMatrixInput!): WorkspaceAppPermissionMatrix!
# Response types
type RolePermissionEntry { role: String!, permissions: [String!]!, locked: Boolean! }
type OrgSystemPermissionMatrix { orgId: ID!, entries: [RolePermissionEntry!]! }
type WorkspaceAppPermissionMatrix { workspaceId: ID!, entries: [RolePermissionEntry!]! }
Locked entries (SUPERADMIN, OWNER) are included in query responses with locked: true. The mutation only accepts the configurable entries — locked entries are ignored if submitted.
Seeding
Called automatically on creation — do not call manually:
// OrganizationService.create() → after saving org:
orgPermissionMatrixService.seedDefaults(newOrg.getId())
// WorkspaceService → after saving each workspace:
workspacePermissionMatrixService.seedDefaults(workspace.getId())
If you add a new workspace type or creation path, add the seedDefaults call.
Caching
Caffeine cache, 5-minute TTL. Cache keys: orgSystemPermissions::${orgId} and workspaceAppPermissions::${workspaceId}. Evicted automatically by setMatrix(). If you add new mutation paths that change permissions, add @CacheEvict to those methods.
Frontend
Hooks
useSystemPermissions() — resolves org-level system permissions for the current user:
import { useSystemPermissions } from '@/features/bootstrap/hooks/use-system-permissions'
import { SystemPermission } from '@/graphql/generated/graphql'
const { can, isLoading } = useSystemPermissions()
if (can(SystemPermission.ManageTeams)) { /* show button */ }
- Queries
orgSystemPermissionMatrix(orgId)usingactiveWorkspace.orgId - Returns
falsefor all permissions while loading - Location:
src/features/bootstrap/hooks/use-system-permissions.ts
useApplicationPermissions() — resolves workspace-level app permissions for the current user:
import { useApplicationPermissions } from '@/features/applications/hooks/use-application-permissions'
import { ApplicationPermission } from '@/graphql/generated/graphql'
const { can, isLoading } = useApplicationPermissions()
if (can(ApplicationPermission.Decide)) { /* show decision button */ }
// Raw string literals also work at runtime (TypeScript prefers enum values):
if (can('DECIDE')) { ... }
- Queries
workspaceAppPermissionMatrix(workspaceId)usingactiveWorkspace.id - Falls back to
DEFAULT_ROLE_PERMISSIONSwhile loading (defined in same file) - Location:
src/features/applications/hooks/use-application-permissions.ts
Query keys
queryKeys.orgSystemPermissionMatrix(orgId) // ['orgSystemPermissionMatrix', orgId]
queryKeys.workspaceAppPermissionMatrix(workspaceId) // ['workspaceAppPermissionMatrix', workspaceId]
Sidebar visibility
app-sidebar.tsx calls useSystemPermissions() and passes can to getOrganizationSidebarData(). Nav items gated by permissions check can(SystemPermission.X). Example:
// sidebar-data.tsx
...(_can(SystemPermission.ManageSystemPermissions) ? [{ title: '...', url: '...' }] : [])
Configuration pages
| Page | File | Permission to access |
|---|---|---|
| Application Permissions | src/features/application-setup/permissions/index.tsx | MANAGE_APPLICATION_PERMISSIONS (enforced backend) |
| System Permissions | src/features/organizations/settings/permissions/index.tsx | MANAGE_SYSTEM_PERMISSIONS (enforced backend) |
Both pages load the matrix on mount, derive locked/configurable roles from entries[].locked, and save with the respective mutation. The UI keeps SUPERADMIN (and OWNER for app permissions) visually locked with a lock icon and "All Access" badge.
How to add a new permission
1. Add to the enum (backend)
// SystemPermission.java
EXPORT_DATA,
// or ApplicationPermission.java
EXPORT_DOCUMENTS,
2. Add to default matrix (service)
In OrgPermissionMatrixService.seedDefaults() (or WorkspacePermissionMatrixService):
// Add to the DEFAULT_MATRIX map
permissions.put("OWNER", EnumSet.of(..., SystemPermission.EXPORT_DATA));
permissions.put("ADMIN", EnumSet.of(..., SystemPermission.EXPORT_DATA));
3. Update GraphQL schema enum
# permissions.graphqls
enum SystemPermission {
...
EXPORT_DATA
}
4. Guard the new operation
@PreAuthorize("@axveroRolePermissionEvaluator.hasUserSystemPermission(#orgId, 'EXPORT_DATA')")
public ExportResult exportData(@Argument UUID orgId) { ... }
5. Run codegen (frontend)
cd frontend && npx graphql-codegen
The new enum value appears in SystemPermission / ApplicationPermission in src/graphql/generated/graphql.ts.
6. Add to permission page metadata (frontend)
In the relevant permissions page (index.tsx), add to PERMISSION_META:
[SystemPermission.ExportData]: {
labelKey: 'systemPermissions.permissions.exportData',
descriptionKey: 'systemPermissions.permissions.exportData.description',
category: 'config',
}
Add translation keys to all three locale files.
Common pitfalls
| Pitfall | Fix |
|---|---|
New entity extends BaseEntity | Don't — BaseEntity uses InheritanceType.JOINED requiring slug/entityType. Permission entities use plain @Id @GeneratedValue(strategy = GenerationType.UUID). |
@PreAuthorize on read endpoints replaced | Don't replace membership checks on read queries. Only write/mutate operations use permission matrix checks. |
can() returns false unexpectedly | Check that seedDefaults() was called for the org/workspace. Query org_system_permission table directly to verify rows exist. |
| Adding workspace write guard without org resolution | Use @workspaceRepository.findOrgIdByWorkspaceId(#workspaceId) in SpEL to resolve orgId when only workspaceId is available. |
| OWNER appears configurable in app permissions | OWNER is locked server-side — entries[].locked = true for OWNER in workspace matrix. UI derives lock state from this flag. |
DEFAULT_ROLE_PERMISSIONS missing new permission | The fallback only applies while the matrix is loading. Add the new permission to the fallback map in use-application-permissions.ts. |