Skip to main content

Technical Architecture

Developer reference. Covers the full entity model, permission evaluation, invitation flows, and system component boundaries as implemented after the IAM & Membership Refactor (March 2026).


System Components

Division of responsibilities:

SystemOwns
KeycloakUser accounts, org membership, account setup emails, OIDC tokens
AMS (Spring Boot)Teams, memberships, workspace assignments, roles, permission evaluation, applications, documents, invitation activation (via scheduler polling)
PostgreSQLAll AMS-managed data — authoritative source for roles and access

Full Entity Relationship Diagram

Reflects the post-refactor entity model (IAM & Membership Refactor, March 2026).


Permission Evaluation

All permission checks are implemented in AxveroRolePermissionEvaluator and resolved via DB queries — no Keycloak group reads.

Effective Role Computation

effectiveRole = member.roleOverride ?? team.baseRole

JPQL Query Patterns

Staff workspace access check:

SELECT COUNT(*) > 0
FROM Member tm
JOIN Team t ON t.id = tm.teamId
JOIN TeamWorkspaceAssignment twa ON twa.teamId = t.id
WHERE twa.workspaceId = :workspaceId
AND tm.userId = :userId
AND COALESCE(tm.roleOverride, t.baseRole) IN :roles

Org-level role check:

SELECT COUNT(*) > 0
FROM Member tm
JOIN Team t ON t.id = tm.teamId
WHERE t.orgId = :orgId
AND tm.userId = :userId
AND COALESCE(tm.roleOverride, t.baseRole) IN :roles

Workspace discovery for bootstrap:

-- Staff: accessible via team membership
SELECT DISTINCT w FROM OrgWorkspace w
WHERE w.id IN (
SELECT twa.workspaceId FROM TeamWorkspaceAssignment twa
WHERE twa.teamId IN (
SELECT m.teamId FROM Member m WHERE m.userId = :userId
)
)

-- Clients: assigned to org, workspace resolved via org FK
SELECT DISTINCT w FROM OrgWorkspace w
JOIN Client c ON c.orgId = w.orgId
WHERE c.userId = :userId

Staff Invitation Sequence

On completion (detected by KeycloakSyncScheduler.syncPendingInvitations, runs every 2 minutes):

Why executeActionsEmail instead of inviteExistingUser: inviteExistingUser generates an org-join link. Since we call addOrganizationMember first (to make the user immediately usable), clicking the join link fails with "already a member." executeActionsEmail sends a pure account-setup link (verify email, set password, update profile) with no org-join action — this works correctly regardless of org membership state.


Async Job System

Long-running operations (demo org seeding, future: report generation, bulk exports) use a DB-backed async job pattern. The mutation returns immediately; the frontend polls for progress.

Mutation → asyncJobService.createJob(type) → PENDING row
→ @Async method fires (different Spring bean — AOP proxy requirement)
→ markRunning / updateProgress / complete | fail
Frontend → polls jobProgress(jobId) every 1 s via TanStack Query refetchInterval
→ stops polling when status = COMPLETED | FAILED

Key rules:

  • AsyncJob must NOT extend BaseEntity (no slug/entityType required)
  • currentStep stores an i18n key — frontend resolves via t(currentStep)
  • resultPayload is a TEXT column containing a JSON string
  • @Async method must live in a separate Spring bean from its caller (self-invocation bypasses AOP proxy)
  • ObjectMapper MAPPER = new ObjectMapper() — static final, not a Spring bean

See docu/docs/ai/claude/rules/async-jobs.md for the full wiring guide and pitfalls list.


Scheduler Overview

KeycloakSyncScheduler runs two recurring jobs:

JobSchedulePurpose
syncPendingInvitationsEvery 2 minutesPolls Keycloak for INVITED members/clients whose requiredActions list is empty → sets status = ACTIVE on the Member / Client record
syncAllUsersEvery 100 minutesEnsures all Keycloak realm users have an AMS User record (onboarding sync)

OrphanInvitationCleanupScheduler runs daily at 03:00 to delete abandoned invitation accounts.

Orphan Cleanup Scheduler

Runs daily at 03:00 (OrphanInvitationCleanupScheduler).


Package Structure

com.axvero.ams.core
├── user/ User, UserService, UserController
├── organization/ Organization, OrganizationService, OrgPublicProfile
├── workspace/ Workspace (base), OrgWorkspace, UserWorkspace
│ └── domain/ WorkspaceRepository, WorkspacePurpose
├── team/ Team, TeamService, TeamController
│ └── domain/ TeamRepository, TeamWorkspaceAssignment, TeamType
├── member/ Member (= TeamMember), MemberService
├── client/ Client, ClientService, ClientController
├── application/ Application, ApplicationService
├── document/ DocumentDefinition, ApplicationTemplate, ApplicationStatus
├── job/ AsyncJob entity, AsyncJobService, AsyncJobController
│ └── domain/ AsyncJobRepository, AsyncJobStatus, AsyncJobType
├── keycloak/ KeycloakService, KeycloakSyncScheduler, KeycloakEventHandler
│ └── OrphanInvitationCleanupScheduler
├── config/
│ └── security/ AxveroRolePermissionEvaluator, SecurityConfig
└── slug/ SlugService

GraphQL Schema Layout

src/main/resources/graphql/
├── queries.graphqls Root Query type
├── mutations.graphqls Root Mutation type
├── common.graphqls Shared scalars + pagination types
├── users.graphqls User, UserProfile types
├── organizations.graphqls Organization, OrgRelationship, OrgPublicProfile types
├── workspaces.graphqls Workspace, OrgWorkspace, WorkspacePurpose types
├── teams.graphqls Team, TeamWorkspaceAssignment types
├── members.graphqls Member (TeamMember) types
├── clients.graphqls Client types
├── application.graphqls Application types
├── documents.graphqls DocumentDefinition, ApplicationTemplate, ApplicationStatus
├── workflow.graphqls WorkflowStep, WorkflowEngine types
├── job.graphqls AsyncJob, AsyncJobStatus, AsyncJobType, jobProgress query
├── bootstrap.graphqls BootstrapInfo type
└── admin.graphqls (planned) Admin-only mutations

Key Design Decisions

DecisionTechnical Rationale
Team IDs generated by AMS (UUID.randomUUID())Decouples entity creation from Keycloak availability — team creation never fails if Keycloak is down
team.baseRole + member.roleOverride with COALESCESingle DB query for effective role — no join to a roles table, no list of per-member roles
TeamWorkspaceAssignment join tableAllows many-to-many team↔workspace without modifying either entity; workspace discovery is a simple IN subquery
Keycloak group role hierarchy removedAll permission evaluation is in AMS DB — no Keycloak group reads at runtime. Simpler, faster, no Keycloak dependency on hot path
Client.orgId (not workspaceId)Clients belong to an org as a whole, not to a specific workspace. Org-scoping avoids tying a client to a single workspace and allows them to participate in applications across all org workspaces
executeActionsEmail instead of inviteExistingUserinviteExistingUser creates an org-join link. Since the user is already added to the org via addOrganizationMember, clicking that link fails with "already a member." executeActionsEmail sends a pure account-setup link (verify + password + profile) with no org-join action.
Invitation activation via scheduler pollingKeycloak does not yet post events to AMS. The scheduler polls UserRepresentation.getRequiredActions() every 2 minutes — empty list means the user completed onboarding.
Accounts pre-created on invitationOffice can build applications and assign people immediately. Orphan cleanup handles the abandoned-invite case.
OrgPublicProfile invariantAlways created with OrgWorkspace — there must never be an org workspace without a public profile
Invitation fields merged into Client / MemberThe Keycloak account is created at invitation time, so the person IS already a Client / Member — just not yet active. Separate TeamInvitation / ClientInvitation entities (and the BaseInvitation superclass) added complexity without benefit. A single status field (INVITED → ACTIVE) on the existing entity is sufficient. sentDate, expiresAt, reminderSent are nullable columns used only while status = INVITED. Cancel = delete the record + delete the Keycloak account; Resend = call executeActionsEmail again.

Frontend Architecture

Route Tree

src/routes/_authenticated/
├── p/$workspaceSlug/ Personal workspace
│ ├── applications/ Cross-org aggregated applications
│ └── settings/
└── o/$orgSlug/ Organization routes
├── teams/ Team management (org-scoped)
│ └── $teamSlug/ Team detail + members
├── clients/ Client management
├── partners/ Org relationships
├── settings/ Org settings
└── w/$workspaceSlug/ Workspace work context
├── applications/ Applications in this workspace
│ └── $id/ Application detail
├── messages/ Workspace messages
└── setup/ Templates, docs, workflow