Skip to main content

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:

MatrixEntityServiceCache key
System (org-level)OrgSystemPermissionOrgPermissionMatrixServiceorgSystemPermissions
Application (workspace-level)WorkspaceAppPermissionWorkspacePermissionMatrixServiceworkspaceAppPermissions

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) using activeWorkspace.orgId
  • Returns false for 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) using activeWorkspace.id
  • Falls back to DEFAULT_ROLE_PERMISSIONS while 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]

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

PageFilePermission to access
Application Permissionssrc/features/application-setup/permissions/index.tsxMANAGE_APPLICATION_PERMISSIONS (enforced backend)
System Permissionssrc/features/organizations/settings/permissions/index.tsxMANAGE_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

PitfallFix
New entity extends BaseEntityDon't — BaseEntity uses InheritanceType.JOINED requiring slug/entityType. Permission entities use plain @Id @GeneratedValue(strategy = GenerationType.UUID).
@PreAuthorize on read endpoints replacedDon't replace membership checks on read queries. Only write/mutate operations use permission matrix checks.
can() returns false unexpectedlyCheck that seedDefaults() was called for the org/workspace. Query org_system_permission table directly to verify rows exist.
Adding workspace write guard without org resolutionUse @workspaceRepository.findOrgIdByWorkspaceId(#workspaceId) in SpEL to resolve orgId when only workspaceId is available.
OWNER appears configurable in app permissionsOWNER is locked server-side — entries[].locked = true for OWNER in workspace matrix. UI derives lock state from this flag.
DEFAULT_ROLE_PERMISSIONS missing new permissionThe fallback only applies while the matrix is loading. Add the new permission to the fallback map in use-application-permissions.ts.