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

Member.role is a direct MemberRole field — there is no baseRole on Team and no roleOverride on Member. The role stored on the Member record is the effective role.

JPQL Query Patterns

Staff workspace access check (actual JPQL from MemberRepository):

SELECT COUNT(m) > 0
FROM Member m
WHERE m.user.id = :userId
AND m.team.id IN (
SELECT twa.teamId FROM TeamWorkspaceAssignment twa
WHERE twa.workspaceId = :workspaceId
)
AND m.role IN :roles

Org-level role check (via OrgMemberRepository — uses OrgRole, not MemberRole):

SELECT COUNT(om) > 0
FROM OrgMember om
WHERE om.orgId = :orgId
AND om.userId = :userId
AND om.role 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
Member.role as a direct MemberRole fieldSingle DB query for effective role — no join to a roles table, no baseRole/roleOverride indirection
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