Skip to main content

Access Control — Design Document

Purpose and scope

This document describes the access-control model for Serendipity's core customer-engagement domain — Accounts (Organisations) and Contacts (Individuals) — and how users, groups, and permissions are managed and enforced across the stack.

It is a design document, not an implementation plan. It captures the model, the decisions behind it, and the open questions that implementation must resolve. It assumes Spring Security for enforcement in the Spring Boot services, Keycloak for identity and coarse-grained role/group management, and the BFF as the security boundary for the Angular PWA. Open Policy Agent is out of scope for now.

Contexts

Actors

The people who interact with Serendipity fall into a small number of categories, each with different responsibilities and access needs.

  • System Admin — the person who operates the platform. Manages users, groups, realms, and the system itself. Full access to all data and all operations.
  • Team Leader — a manager of a team of sales reps. Can see and act on the entities their team owns or is assigned, can assign and reassign entities to team members, and can create entities on behalf of the team.
  • Sales Rep — an end user who works with specific accounts and contacts. Owns, is assigned, or is scoped to a subset of entities. Creates and edits the entities they are entitled to.
  • Read-Only Viewer — a reporting, oversight, or external stakeholder who can view entities in scope but cannot edit or delete.
  • Account / Contact Owner — not a global role; a relationship. A user who owns a specific entity can act on it regardless of their global role (within the bounds of the role model).

Why this matters

Without a model, each service or endpoint invents its own notion of who can do what, and the Angular PWA, the BFF, the Party Service, and Camunda can drift apart. The model below gives each layer a shared vocabulary: roles, groups, ownership, assignment, scope, and operations.

Identity model (Keycloak)

Keycloak is the system of record for users. It is authoritative for:

  • User accounts (login, credentials, profile attributes).
  • Groups (teams, regions, functions).
  • Coarse roles (system admin, team leader, sales rep, read-only viewer).

Users

Each human who uses Serendipity has a Keycloak user account. The user's identity in Serendipity is carried by the access token Keycloak issues — specifically the sub (Keycloak user ID) and, for display purposes, preferred_username or email. The sub is the stable, non-reusable identifier; the preferred_username may change (e.g. if the user changes their username) and should not be used as the identity for ownership or assignment comparisons.

Groups

Groups represent teams, regions, or functional groupings. Examples:

  • serendipity-admins — system administrators
  • serendipity-viewers — read-only users
  • serendipity-team-sydney — Sydney sales team
  • serendipity-team-melbourne — Melbourne sales team

Groups are managed in Keycloak. A user belongs to one or more groups. Group membership can be mapped into the access token (as a groups claim or mapped to roles), which lets downstream services make group-based decisions without querying Keycloak on every request.

Roles

Roles are coarse-grained and global — they describe what a user is, not what a user can do to a specific entity. They live in Keycloak as realm roles and are carried in the access token. (See Authorization model for the full role hierarchy and access scope.)

How roles get into the token is described in Token design. In summary:

  • Realm roles (realm_access.roles) carry the coarse role (admin, team-leader, sales-rep, viewer). Realm roles are included in the token by default — no mapper is needed. This is the simplest option and the one to reach for first. Realm roles are chosen over client (resource) roles because the role describes the user's place in the organization, not a privilege on a specific client.
  • Groups (groups claim) carry team membership (e.g. serendipity-team-sydney). Groups are not in the token by default — a Group Membership mapper must be configured. Groups are used for team-based access, not as a substitute for roles.

The Keycloak role names and the Spring Security authority names are different things. The token carries Keycloak names (admin, team-leader, sales-rep, viewer); the enforcement layer checks Spring Security authorities (ROLE_ADMIN, ROLE_TEAM_LEADER, ROLE_REP, ROLE_VIEWER) via a JwtGrantedAuthoritiesConverter or a custom converter. The two naming schemes do not have to match, and they should not be assumed to.

Groups vs Serendipity-internal assignment

This is an important distinction that the model resolves in favor of Keycloak groups for team membership.

  • Groups in Keycloak answer "which team is this user on." A group is a collection of users; membership is coarse, managed centrally in Keycloak, and propagates into the access token (once the Group Membership mapper is configured — see Token design). The Keycloak group name is the team name (e.g. the serendipity-team-sydney group is the team). There is no separate "team entity" in Serendipity — the group is the team.
  • Serendipity-internal assignment answers "which entity is this user entitled to act on right now." This is fine-grained, possibly dynamic, stored in the Party Service's data model, and not derived from Keycloak group membership. An entity can be assigned to a user (assignedTo) or to a team (assignedToTeam) for a purpose that can be audited, time-bounded, or driven by workflow (e.g. a contact assigned to a rep for a follow-up cadence).
  • Keycloak realm roles answer "what is this user's coarse job function." The role (admin, team-leader, sales-rep, viewer) is global, not per-entity, and is carried in the token's realm_access.roles claim.

The model is a hybrid of these three Keycloak concepts, chosen so that team membership and coarse role come from Keycloak (via the token), and per-entity entitlement comes from Serendipity's data model. There is no Serendipity-internal team entity in this model. If Serendipity later needs an internal notion of a team that is distinct from a Keycloak group (e.g. for internal team composition that does not depend on Keycloak), that is an extension — the current model uses Keycloak groups as teams.

Authorization model

Roles

Roles are a Keycloak concept — they answer "what is this user's coarse job function." Each Keycloak role is a realm role (not a client role). The chosen roles are:

Keycloak role nameJob functionAccess scope (Dynamics analogy)
adminSystem administrator — operates the platform, manages users and groupsOrganization (Global) — any record, any operation. View, edit, delete, assign, export, manage users. The most powerful role; restricted to a few people.
team-leaderManages a team of repsBusiness Unit (Local) — records owned by or assigned to members of their team. View and edit the team's entities, assign/reassign within the team, create entities on behalf of the team.
sales-repEnd user who works with specific accounts and contactsUser (Basic) — records they own or are assigned to. View and edit owned/assigned entities, create new entities (owned by self unless assigned to a team). The typical access for sales and service representatives.
viewerReporting, oversight, or external stakeholder with limited accessUser (read-only) — see Viewer semantics below. No mutation capability.
  • The Keycloak role names are lower-case, hyphenated, and human-readable — Keycloak's convention. They are also the values that appear in the token's realm_access.roles claim.
  • These are distinct from the Spring Security authority names that the enforcement layer checks: ROLE_ADMIN, ROLE_TEAM_LEADER, ROLE_REP, ROLE_VIEWER. A JwtGrantedAuthoritiesConverter (or a custom converter) maps the Keycloak role names to Spring Security authorities, adding the ROLE_ prefix. The two naming schemes do not have to match, and they should not be assumed to.
  • hasRole("ADMIN") in Spring Security checks for the authority ROLE_ADMIN — it adds the ROLE_ prefix automatically. So @PreAuthorize("hasRole('ADMIN')") matches a user who has the ROLE_ADMIN authority, which corresponds to the Keycloak role admin. The same applies to hasRole("TEAM_LEADER")ROLE_TEAM_LEADER (Keycloak role team-leader) and hasRole("REP")ROLE_REP (Keycloak role sales-rep).
  • Each user has one coarse role. A user may have more than one role only if a role is a composite that contains another — composite roles are discouraged for permission tracing, so the model assumes one coarse role per user, with Keycloak group membership used for team-based access (see Teams).
  • Realm roles are chosen over client (resource) roles because the role describes the user's place in the organization, not a privilege scoped to a specific client. This keeps the role model consistent across all services that validate the same token.

Ownership

Ownership is a relationship between a user and an entity. If a user owns an entity, the user can act on it within the bounds of their role.

  • An entity has an ownedBy field — the user ID (Keycloak sub) of the owner.
  • The owner can view, edit, and delete their own entity (assuming their role permits mutation — a ROLE_VIEWER who owns an entity can view it but still not edit it; ownership does not override the coarse role).
  • A team leader can act on entities owned by members of their team.
  • An admin can act on any entity, regardless of ownership.

Ownership is the primary mechanism for per-user access to entities. It is stored in the Party Service's data model, not in Keycloak. The ownedBy claim is a Keycloak user ID, so the Party Service can compare it against the sub in the validated access token.

Assignment

Assignment is broader than ownership. An entity can be assigned to a user or to a team. The assignee can act on the entity within the bounds of their role, even if they do not own it.

  • An entity has an assignedTo field — the user ID of the assignee.
  • An entity can also have an assignedToTeam field — the name or ID of a team (which maps to a Keycloak group, e.g. serendipity-team-sydney).
  • A user can act on an entity assigned to them (by user or by team) within the bounds of their role.
  • A team leader can act on entities assigned to their team.
  • An admin can act on any entity, regardless of assignment.

Assignment is the mechanism for "I am responsible for this entity even though I don't own it." Examples: a rep is assigned to an account they did not create; a contact is assigned to a rep for a follow-up cadence; a team is assigned a set of contacts for a campaign.

Teams

A team is a group of users who share responsibility for a set of entities. Teams are represented in Keycloak as groups (e.g. serendipity-team-sydney). A team leader is a user who is on the team and has ROLE_TEAM_LEADER. The team leader can act on entities owned by or assigned to any member of their team.

A user can be on more than one team; the enforcement layer checks all of the user's groups (from the token) against both the entity's assignedToTeam and the owning user's team. If any of the current user's groups matches, team-based access is granted.

A team is a Keycloak group. The group name is the team name. There is no separate "team entity" in Serendipity — the group is the team, and the group name (e.g. serendipity-team-sydney) is stored as the value of assignedToTeam on an entity when that entity is assigned to the team.

Viewer semantics

A viewer is read-only. A viewer cannot create, edit, delete, assign, or export entities. A viewer can view entities to which they have access under the User (Basic) access scope — that is, entities they own, entities assigned to them (by user or by team), and entities owned by or assigned to a member of a team they belong to. This is the read-only version of the sales-rep role's access scope.

The intended semantics are: a viewer is someone who needs to see a defined set of records (their own, their team's) but should not be able to mutate them. Common examples: a reporting user, a manager doing oversight, an external stakeholder with limited access.

A viewer does not have access to any record (the Organization/Global scope) — only to records in their User (Basic) scope. If a viewer needs to see all records org-wide (e.g. a read-only administrator), that is a different role or a configuration choice, not the default viewer.

There is no scope-based visibility for viewers (scope is not part of the model — see Scope (electorate / region)). A viewer's visibility is defined by ownership, assignment, and team membership — the same as a rep's, but read-only.

Further reading:

  • Operation matrix — the viewer column shows view-only access for own and team's entities, and no access to the org-wide scope.
  • What the Angular PWA does — the PWA renders view-only UI for viewers; it does not grant any mutation capability.

This is a proposed default; if a different viewer semantics is wanted (e.g. view-only access to the team's records only, or org-wide read-only), it can be adjusted. Either way, the viewer has no mutation capability.

Note: a requirement later, it is an extension: add an electorate or region field on the entity, a scope claim (or group) on the user, and a scope check in the enforcement logic. Until then, the model above works without it.

Operations

The operations that matter for Accounts and Contacts are:

  • View — see the entity's data.
  • Create — create a new entity.
  • Edit — update an existing entity.
  • Delete — remove an entity (soft delete, per ADR-0002).
  • Assign / Reassign — change who the entity is assigned to.
  • Export — export entity data (read access plus export permission).

Operation matrix

Accounts (Organisations)

Operationadminteam-leadersales-repviewer
View any accountYesNoNoNo
View own accountYesYesYesYes
View team's account (owned by or assigned to a team member)YesYesNoYes
Create accountYesYes (on behalf of team)Yes (owned by self)No
Edit own accountYesYesYesNo
Edit team's account (owned by or assigned to a team member)YesYes (leader of the owning/assigned team)NoNo
Edit any accountYesNoNoNo
Delete own accountYes (soft)Yes (soft)Yes (soft)No
Delete team's account (owned by or assigned to a team member)Yes (soft)Yes (soft)NoNo
Delete any accountYes (soft)NoNoNo
Assign / reassignYesYes (within team: to a team member or to the team)NoNo
ExportYesYes (team's)Yes (owned/assigned)No

Contacts (Individuals)

Operationadminteam-leadersales-repviewer
View any contactYesNoNoNo
View own contactYesYesYesYes
View team's contact (owned by or assigned to a team member)YesYesNoYes
Create contactYesYes (on behalf of team)Yes (owned by self)No
Edit own contactYesYesYesNo
Edit team's contact (owned by or assigned to a team member)YesYes (leader of the owning/assigned team)NoNo
Edit any contactYesNoNoNo
Delete own contactYes (soft)Yes (soft)Yes (soft)No
Delete team's contact (owned by or assigned to a team member)Yes (soft)Yes (soft)NoNo
Delete any contactYes (soft)NoNoNo
Assign / reassignYesYes (within team: to a team member or to the team)NoNo
ExportYesYes (team's)Yes (owned/assigned)No

Notes on the matrix:

  • "Yes (team's accounts/contacts)" means the team-leader can act on entities owned by or assigned to members of their team. The team-leader's team is derived from their Keycloak groups (e.g. the team-leader is in serendipity-team-sydney and the entity is owned by or assigned to a user who is also in serendipity-team-sydney).
  • "Owned/assigned" means the sales-rep can act on entities they own or are assigned to (by user or by team).
  • "View own/team's" for viewers means the viewer can see entities they own or that are assigned to their team (by user or by team) — the same entities a sales-rep can see, but read-only. The viewer has no access to the org-wide scope (View any is No for all operations).
  • "Soft" means soft delete (set toDate), per ADR-0002. Hard delete is not part of this model.
  • Ownership does not override the coarse role. A viewer who owns an entity can view it but cannot edit it. Ownership grants access within the permissions of the role.

Ownership and assignment in the data model

The Party Service's entities currently have a root aggregate Party with publicId, type, and the audit fields. Individual and Organisation extend Party via a shared primary key.

To support ownership and assignment, the model needs to add (or already have) fields that carry:

  • ownedBy — the Keycloak user ID (sub) of the owner.
  • assignedTo — the Keycloak user ID (sub) of the assignee (optional).
  • assignedToTeam — the team identifier (Keycloak group name) the entity is assigned to (optional).

These are Serendipity-internal fields. They are not Keycloak groups — they are stored in the Party Service's database and populated by the application when entities are created or assigned.

The BFF or the PWA (via the BFF) sets these fields when creating or assigning entities. The Party Service enforces that the caller is entitled to set them — e.g. a rep can set ownedBy to their own sub, but cannot set it to someone else's sub; a team leader can assign an entity to a team member; an admin can assign to anyone.

Token design

For the authorization model to work, the access token Keycloak issues must carry enough information for the Party Service to make authorization decisions without calling Keycloak on every request.

The token should include:

  • sub — the Keycloak user ID, the stable, non-reusable identifier for the user. This is the primary identifier for ownership and assignment comparisons; it is the value stored in ownedBy and assignedTo on the entity. The sub is never reused by Keycloak, even if the user changes their username, so it is safe to store as a long-lived reference.
  • preferred_username or email — human-readable identity, present if the user has a username or email and the profile scope is granted. Use these for display only — not for ownership or assignment comparisons, because usernames can change and email addresses can be updated.
  • realm_access.roles — the coarse role (admin, team-leader, sales-rep, viewer). Realm roles are included in the token by default — no mapper needs to be configured. This is the simplest option and the one to reach for first. Realm roles (not client roles) are chosen because the role describes the user's place in the organization, not a privilege on a specific client.
  • groups — the Keycloak groups the user belongs to (e.g. serendipity-team-sydney), used for team-based authorization. Groups are not in the token by default — you must add a Group Membership mapper to emit them. Without this mapper, every user resolves to the default role, which is the single most common Keycloak configuration mistake.

How these get in the token:

  • Realm roles — emitted in realm_access.roles by default. To restrict which roles appear, configure the role mapper in the client scope (the default role list mapper includes all realm roles; a custom mapper can filter).
  • Groups — add a Group Membership protocol mapper to the client's dedicated scope (or to the realm's default client scope). Mapper settings:
    • Mapper type: Group Membership
    • Token Claim Name: groups
    • Claim JSON Type: String (Keycloak emits an array)
    • Full group path: Off — use flat group names (serendipity-team-sydney, not /serendipity-team-sydney). Flat names are simpler for the Party Service to compare against assignedToTeam. If you need the path for some reason, turn it on — but the enforcement layer then needs to handle the leading slash.
    • Add to access token: On (the Party Service reads the access token)
    • Add to ID token: On or off depending on whether the PWA needs groups (the PWA may want group info for UI rendering)
  • preferred_username — a standard claim; present if the profile scope is granted.
  • email — added via the Email protocol mapper if needed for display. Not required for authorization.

If the token's groups are very large (a user belongs to many groups), consider limiting the groups claim or using a custom mapper that emits only the relevant groups. For the model as described — a user on one or two teams — the default groups claim is fine.

The Party Service, once it is an OAuth 2.0 resource server, validates the token and extracts these claims. The realm roles become GrantedAuthority instances in Spring Security's SecurityContext, mapped from the Keycloak role names to the Spring Security authority names (e.g. adminROLE_ADMIN). The groups become a separate source for team membership checks (the Party Service reads the groups claim and checks whether any of them match the entity's assignedToTeam or the owning user's team). The sub is available as the principal — use Authentication.getName() or extract it explicitly from the JWT to get the stable identity.

Enforcement in the Party Service

Each enforcement point is a complete, self-contained check. The enforcement layer evaluates role, ownership, assignment, and team membership together; a request is allowed if it passes the checks required for the operation. Admins are handled separately: a ROLE_ADMIN passes every enforcement point without further per-entity checks, because the Organization (Global) access scope grants access to all entities. For non-admin users, the enforcement layer evaluates the per-entity rules — ownership, assignment, and team membership — in combination. The enforcement sketch at the end of this section shows how these combine in a single helper method.

The enforcement layer uses the sub claim (the Keycloak user ID) as the stable identity. The sub is extracted from the validated JWT by Spring Security's OAuth 2.0 resource server support — it is available as Authentication.getName() in the SecurityContext after the JWT is validated, and as the JWT's sub claim if the code reads it directly from the token. The enforcement layer does not use preferred_username or email as identity, because those can change. See Token design for the available claims and Identity model for why sub is the stable identifier.

The enforcement layer checks role-based rules with Spring Security's @PreAuthorize and hasRole(). hasRole("ADMIN") checks for the authority ROLE_ADMIN — Spring Security adds the ROLE_ prefix automatically. So the expression matches the authority name without the prefix. The same applies to hasRole("TEAM_LEADER") (matches ROLE_TEAM_LEADER) and hasRole("REP") (matches ROLE_REP). See Roles for the chosen Keycloak role names and the corresponding Spring Security authority names.

  1. Role-based rules@PreAuthorize("hasRole('ADMIN')") for operations that only admins can do (e.g. delete any account). hasRole() in Spring Security checks for the ROLE_ prefix automatically (i.e. hasRole("ADMIN") checks for ROLE_ADMIN). So the expression matches the authority name without the prefix.

@PreAuthorize("hasRole('TEAM_LEADER') or hasRole('ADMIN')") for operations that team leaders and admins can do.

@PreAuthorize("hasRole('REP') or hasRole('TEAM_LEADER') or hasRole('ADMIN')") for operations that reps, team leaders, and admins can do.

  1. Ownership-based rules — the enforcement layer checks whether the current user owns the entity being accessed. Ownership is determined by comparing the current user's sub (from the validated JWT) against the entity's ownedBy value — the entity is owned by the current user if these are equal. The ownedBy field stores the owner's sub, not their preferred_username or email, precisely so that ownership survives a username change. Example: the current user's sub equals the entity's ownedBy. Ownership can be checked with a custom AuthorizationManager or an @PreAuthorize SpEL expression that reads the entity and compares ownedBy to Authentication.getName().

  2. Assignment-based rules — the enforcement layer checks whether the current user is assigned to the entity. Assignment is determined by comparing (a) the current user's sub against the entity's assignedTo, and (b) the current user's groups (from the token) against the entity's assignedToTeam. Example: the current user's sub equals the entity's assignedTo, or the current user is a member of the team named by assignedToTeam. Assignment can be user-level (the entity is assigned to a specific user) or team-level (the entity is assigned to a team, and any member of that team can act on it). Assignment to a team is represented by storing the Keycloak group name in assignedToTeam — there is no separate "team entity"; the group is the team (see Teams).

  3. Team-based rules — the enforcement layer checks whether the current user is a member of the team that owns or is assigned the entity. The current user's team membership is derived from their Keycloak groups (from the groups claim in the validated token), and the entity's team is derived from its assignedToTeam (or, for owned entities, the owning user's group membership). A team leader (ROLE_TEAM_LEADER) who is a member of the team can act on team-owned or team-assigned entities. Example: the current user is a member of serendipity-team-sydney, and the entity's assignedToTeam is serendipity-team-sydney, or the entity's ownedBy belongs to a user who is also a member of serendipity-team-sydney.

  4. No scope-based rules — scope (electorate/region) is not part of the model. See Scope (electorate / region). If scope becomes a requirement later, a scope-based rule can be added then.

A sketch of how this looks in Spring Security:

// Pseudo-code — not a complete implementation
@RestController
public class ContactController {

@PutMapping("/contacts/{publicId}")
@PreAuthorize("hasRole('ADMIN') or @contactAcl.canEditContact(#publicId)")
public ResponseEntity<IndividualModel> update(
@PathVariable String publicId,
@Valid @RequestBody IndividualUpdateDto updateDto) {
// ...
}
}

// The ACL helper:
@Component
public class ContactAcl {
@PreAuthorize("#contactAcl.canEditContact(#publicId)")
public boolean canEditContact(String publicId) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String currentSub = auth.getName(); // the 'sub' from the token
// Role check: admin can edit anything
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
return true;
}
// Fetch the entity and check ownership / assignment / team
Individual contact = contactRepository.findByPartyPublicId(publicId);
if (contact == null) return false;
if (contact.getParty().getOwnedBy().equals(currentSub)) return true;
if (contact.getParty().getAssignedTo().equals(currentSub)) return true;
if (isMemberOfContactTeam(currentSub, contact.getParty().getAssignedToTeam())) {
// leader on the assigned team can edit
return hasLeaderRole(auth);
}
return false;
}
}

This is the shape of the enforcement. The details (repository methods, team membership lookup, leader role check) are implementation concerns. The model is: role + ownership + assignment + team, all evaluated against the current user from the token.

What the BFF does not enforce

The BFF enforces authentication (the user is who the token says they are) and relays the token to the Party Service. The BFF does not enforce the per-entity authorization rules — those are the Party Service's responsibility. The BFF may do coarse filtering (e.g. if the user is a viewer, the BFF could choose not to forward mutation requests), but real enforcement is downstream. The BFF is not a trust boundary for authorization; it is a trust boundary for identity.

What the Angular PWA does

The PWA shows and hides UI based on the user's roles and groups, derived from the token (if it is a JWT the PWA can decode) or from a BFF user-info endpoint. Concretely:

  • If the user is ROLE_ADMIN, show the full UI (create, edit, delete, assign, export).
  • If the user is ROLE_LEADER, show create, edit, assign for team-scoped entities; show delete for team-owned entities.
  • If the user is ROLE_REP, show create, edit for owned/assigned entities; show delete for owned entities.
  • If the user is ROLE_VIEWER, show view-only UI.

The PWA never decides "can this user edit this contact." It either knows from the token that the user has the broad role to do so, or it asks the backend and reflects the answer (e.g. an edit button that is only enabled when the backend says it's allowed). The PWA is not a trust boundary.

Camunda integration

If a workflow acts on an account or contact (e.g. "new contact onboarding," "follow-up cadence," "complaint handling"), the process is started by a user who has permission to do that thing. The enforcement point is the same as for the REST API — the user must be entitled to create or edit the entity before a process that acts on it is started.

Inside Camunda:

  • The user who starts the process is identified by the token (or a Camunda-specific token if Camunda uses separate auth).
  • Human tasks (e.g. "review new contact," "assign follow-up") are assigned following the same ownership/team model. A task to review a new contact can be assigned to the team leader or the assigned rep.
  • Camunda's own permission model (who can see which process definitions, who can claim which tasks) is configured in Camunda, not in Serendipity. To keep them aligned, the same roles and groups from Keycloak should be available to Camunda's authz.

If the Party Service is an OAuth 2.0 resource server and the BFF relays the token to Camunda (where Camunda is configured to accept it), then the token carries the roles and groups to Camunda as well, and Camunda can use them for its own authorization. If Camunda cannot accept the same token, then the roles/groups need to be propagated some other way (e.g. a separate Camunda client with the same Keycloak groups mapped in).

Data model additions (suggested)

To support the model, the Party Service's entities need to carry fields that represent ownership and assignment. These fields do not exist in the Party Service's entities today — the current Party aggregate has publicId, type, and the audit fields, but no ownership or assignment fields. The fields below are additions to the Party entity (the root aggregate), so that Individual and Organisation inherit them:

  • Party.ownedByString (Keycloak sub), the owner. Not nullable if every entity must have an owner. The value is the Keycloak user ID of the user who owns the entity — a sub from Keycloak, not a Serendipity-internal user ID.
  • Party.assignedToString (Keycloak sub), the assignee. Nullable — not every entity is assigned to a specific user. The value is the Keycloak user ID of the user the entity is assigned to.
  • Party.assignedToTeamString (Keycloak group name), the team the entity is assigned to. Nullable. The value is the Keycloak group name of the team (e.g. serendipity-team-sydney). There is no separate team identifier — the group name is the team identifier.

These fields are added to the Party entity so that Individual and Organisation inherit them. They are populated by the BFF or the PWA (via the BFF) when entities are created or assigned.

If the BFF is responsible for creating entities on behalf of the user, it sets ownedBy to the current user's sub (from the validated token) and assignedTo to the same or to a team member, as appropriate.

If assignment can be done by a team leader or admin, the Party Service enforces that the caller is entitled to set assignedTo to a particular user or assignedToTeam to a particular team.

Open questions

These are decisions that implementation must resolve, not assumptions this document makes:

  1. Do you want group-based team access in the token? Decided: yes. Keycloak groups (groups claim) carry team membership (e.g. serendipity-team-sydney). A Group Membership mapper must be configured to emit them (groups are not in the token by default). Flat group names (full path off) are simpler for the enforcement layer. This is the chosen approach; Serendipity-internal team membership is not used.

  2. Is scope (electorate/region) a real requirement? Decided: no. Scope-based access is not part of the model and is not assumed by default. It is an extension if it becomes a requirement later.

  3. Do you want OPA later? Possibly, but not now. This document assumes Spring Security + Keycloak. If OPA is introduced later, the enforcement layer changes (the Party Service asks OPA "can this user edit this contact?"), but the model (roles, ownership, assignment, teams) stays the same. OPA would evaluate it; it would not redefine it. OPA is out of scope for now.

  4. What are the exact roles? Decided: admin, team-leader, sales-rep, viewer as Keycloak realm role names (lower-case, hyphenated, per Keycloak convention). These map to Spring Security authorities ROLE_ADMIN, ROLE_TEAM_LEADER, ROLE_REP, ROLE_VIEWER via a JwtGrantedAuthoritiesConverter or custom converter. Realm roles (not client roles). One coarse role per user, with Keycloak group membership used for team-based access. See the Roles section for the full role table with job function and access scope.

  5. How does assignment happen? Not decided. Is it done by the user (self-assignment), by a team leader, by an admin, or by workflow (e.g. a Camunda process assigns a follow-up task to a rep)? The enforcement must allow the appropriate callers to set assignedTo and assignedToTeam. This is an implementation decision.

  6. Do you want a Serendipity-internal user/team management UI? Or is all user/group/role management in Keycloak? The model assumes Keycloak for identity and coarse roles, with Serendipity carrying ownership and assignment. If you want a Serendipity UI for managing users and teams, that's an additional feature on top of this model.

What is out of scope

  • Open Policy Agent — deferred.
  • Fine-grained attribute-based access control beyond roles, ownership, assignment, teams, and scope.
  • Data retention, archival, and hard delete beyond the soft-delete model (ADR-0002).
  • Camunda's internal permission model in detail — only the integration points are covered.
  • Multi-tenancy — the model assumes a single tenant for now.
  • Public / versioned API for external consumers — the model covers the BFF-mediated path. If a public API is added, the access-control model for that path is a separate design.