<!doctype html>
<html lang="en">
<head><base href="about:srcdoc"><script>(function(){function n(){parent.postMessage({t:'share:hash',h:location.hash},'*')}addEventListener('hashchange',n);addEventListener('message',function(e){if(e.source!==parent)return;var d=e.data||{};if(d.t==='share:setHash'&&typeof d.h==='string'&&d.h!==location.hash){location.hash=d.h}});addEventListener('DOMContentLoaded',function(){parent.postMessage({t:'share:ready',h:location.hash},'*')});})();</script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Access Control in eRegistrations — Identity, Configuration, Runtime, Presentation</title>
<meta name="description" content="How Keycloak, BPA, Camunda, and DS together decide what a user sees in eRegistrations." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,300..700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<style>
:root {
--paper: #F7F1E2;
--paper-bright: #FBF6E8;
--ink: #1E1A14;
--ink-soft: #3C3528;
--ink-light: #6F6552;
--rule: #D4C9A8;
--rule-strong: #A89A75;
--accent: #8C2E25;
--accent-deep: #5E1F18;
--green: #4F5C36;
--maxw: 78ch;
--gutter: clamp(1.25rem, 3vw, 2.5rem);
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body { margin: 0; }
table { border-collapse: collapse; }
section { scroll-margin-top: 2rem; }
body {
background: var(--paper);
color: var(--ink);
font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
font-size: 18.5px;
line-height: 1.62;
font-weight: 400;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* Subtle paper grain */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1000;
opacity: .035;
mix-blend-mode: multiply;
background-image:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .9 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
a { color: var(--accent-deep); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:hover { color: var(--accent); }
::selection { background: var(--accent); color: var(--paper-bright); }
.doc {
width: min(100% - 2rem, calc(var(--maxw) + 5rem));
margin-inline: auto;
padding-inline: var(--gutter);
padding-block: clamp(2rem, 5vw, 3.5rem) 3rem;
}
/* ---------- Masthead ---------- */
.masthead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding-bottom: .9rem;
border-bottom: 1px solid var(--ink);
font-size: .72rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ink-soft);
font-weight: 500;
}
.masthead .right { color: var(--ink-light); }
/* ---------- Hero ---------- */
.hero { margin-block: 2.5rem 3rem; }
.hero h1 {
font-family: 'Source Serif 4', serif;
font-weight: 600;
font-size: clamp(2rem, 4.5vw, 2.95rem);
line-height: 1.12;
letter-spacing: -0.014em;
margin: 0 0 1.2rem 0;
color: var(--ink);
max-width: 32ch;
}
.hero .standfirst {
font-family: 'Source Serif 4', serif;
font-style: italic;
font-weight: 400;
font-size: 1.12rem;
line-height: 1.5;
color: var(--ink-soft);
max-width: 60ch;
margin: 0;
}
.hero .meta {
margin-top: 1.75rem;
padding-top: 1.1rem;
border-top: 1px solid var(--rule);
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 1.5rem;
row-gap: .35rem;
font-size: .85rem;
max-width: 60ch;
}
.hero .meta dt {
font-family: 'Source Serif 4', serif;
font-size: .68rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ink-light);
font-weight: 600;
align-self: baseline;
padding-top: .15rem;
}
.hero .meta dd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: .8rem;
margin: 0;
color: var(--ink);
overflow-wrap: anywhere;
word-break: break-word;
}
/* ---------- Sections ---------- */
section { margin-block: 3.5rem; }
.section-head { margin-bottom: 1.5rem; }
.section-head .label {
font-family: 'Source Serif 4', serif;
font-size: .68rem;
letter-spacing: .22em;
text-transform: uppercase;
color: var(--accent);
font-weight: 600;
margin-bottom: .35rem;
}
.section-head h2 {
font-family: 'Source Serif 4', serif;
font-weight: 600;
font-size: 1.55rem;
line-height: 1.22;
letter-spacing: -0.006em;
margin: 0 0 .65rem 0;
color: var(--ink);
max-width: 40ch;
}
.section-head::after {
content: "";
display: block;
width: 2.5rem;
height: 1px;
background: var(--ink);
margin-top: .8rem;
}
h3 {
font-family: 'Source Serif 4', serif;
font-weight: 600;
font-size: 1.12rem;
margin: 2.1rem 0 .65rem 0;
color: var(--ink);
}
p { margin: 0 0 .95em; }
p strong { font-weight: 600; color: var(--ink); }
em { font-style: italic; }
/* Inline code */
code {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: .82em;
background: var(--paper-bright);
padding: .08em .35em;
border: 1px solid var(--rule);
border-radius: 2px;
color: var(--accent-deep);
overflow-wrap: anywhere;
}
/* Block code */
pre {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: .76rem;
line-height: 1.55;
background: var(--paper-bright);
border: 1px solid var(--rule);
border-left: 2px solid var(--ink);
padding: .9rem 1.1rem;
margin: 1.25rem 0;
overflow-x: auto;
color: var(--ink-soft);
}
pre code { background: none; border: 0; padding: 0; color: inherit; }
ul, ol { margin: 0 0 1em 1.3em; padding: 0; }
li { margin-bottom: .3em; }
ul li::marker { color: var(--accent); }
blockquote {
margin: 1.5rem 0;
padding: 0 0 0 1.5rem;
border-left: 2px solid var(--accent);
font-style: italic;
font-size: 1rem;
line-height: 1.55;
color: var(--ink-soft);
}
blockquote p:last-child { margin-bottom: 0; }
/* ---------- Tables ---------- */
.table-wrap {
margin: 1.4rem 0;
overflow-x: auto;
border-top: 1.5px solid var(--ink);
border-bottom: 1.5px solid var(--ink);
}
table { width: 100%; font-size: .9rem; line-height: 1.45; }
thead tr { border-bottom: 1px solid var(--ink); }
th {
text-align: left;
padding: .7rem .8rem;
font-family: 'Source Serif 4', serif;
font-weight: 600;
font-size: .68rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ink);
vertical-align: bottom;
}
td {
padding: .65rem .8rem;
border-bottom: 1px solid var(--rule);
vertical-align: top;
color: var(--ink-soft);
}
td.num { font-family: 'JetBrains Mono', monospace; text-align: right; font-size: .85rem; color: var(--ink); }
td strong { color: var(--ink); }
td em { color: var(--ink-light); }
tbody tr:last-child td { border-bottom: 0; }
/* ---------- The four pillars (mental model diagram) ---------- */
.pillars {
margin: 1.5rem 0 1.75rem;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
border-top: 1.5px solid var(--ink);
border-bottom: 1.5px solid var(--ink);
background: var(--paper-bright);
position: relative;
}
.pillar {
padding: 1.1rem 1rem 1.2rem;
border-right: 1px solid var(--rule);
position: relative;
}
.pillar:last-child { border-right: 0; }
.pillar .stage {
font-family: 'Source Serif 4', serif;
font-weight: 600;
font-size: .62rem;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--ink-light);
margin-bottom: .45rem;
}
.pillar .name {
font-family: 'Source Serif 4', serif;
font-weight: 600;
font-size: 1rem;
color: var(--ink);
margin-bottom: .3rem;
}
.pillar .qa {
font-style: italic;
font-size: .8rem;
line-height: 1.4;
color: var(--accent-deep);
margin-bottom: .7rem;
}
.pillar ul {
margin: 0;
padding: 0;
list-style: none;
font-size: .76rem;
line-height: 1.45;
color: var(--ink-soft);
}
.pillar ul li { padding-left: .85rem; position: relative; margin-bottom: .2rem; }
.pillar ul li::before { content: "—"; position: absolute; left: 0; color: var(--rule-strong); }
.pillar ul li code { font-size: .72rem; }
/* CSS arrows between pillars (desktop only) */
.pillar:not(:last-child)::after {
content: "→";
position: absolute;
right: -.55rem;
top: 50%;
transform: translateY(-50%);
font-family: 'Source Serif 4', serif;
font-size: 1rem;
color: var(--accent);
background: var(--paper-bright);
width: 1.1rem;
height: 1.1rem;
line-height: 1.1rem;
text-align: center;
border: 1px solid var(--rule);
border-radius: 50%;
z-index: 2;
}
@media (max-width: 760px) {
.pillars { grid-template-columns: 1fr; }
.pillar { border-right: 0; border-bottom: 1px solid var(--rule); }
.pillar:not(:last-child)::after {
content: "↓";
right: 50%;
top: auto;
bottom: -.55rem;
transform: translateX(50%);
}
}
/* ---------- Group tree / structured callout ---------- */
.tree {
margin: 1.5rem 0;
padding: 1rem 1.25rem;
background: var(--paper-bright);
border: 1px solid var(--rule);
border-left: 2px solid var(--ink);
font-family: 'JetBrains Mono', monospace;
font-size: .8rem;
line-height: 1.6;
color: var(--ink-soft);
}
.tree .root { color: var(--ink-light); }
.tree .inst { color: var(--ink); font-weight: 700; }
.tree .unit { color: var(--ink-soft); }
.tree .annot {
color: var(--ink-light);
font-style: italic;
font-family: 'Source Serif 4', serif;
font-size: .76rem;
padding-left: .5rem;
}
/* ---------- Scenario / timeline ---------- */
.scenario {
margin: 1.5rem 0;
padding: 1rem 1.25rem;
background: var(--paper-bright);
border: 1px solid var(--rule);
border-left: 2px solid var(--accent);
font-family: 'JetBrains Mono', monospace;
font-size: .78rem;
line-height: 1.6;
color: var(--ink-soft);
}
.scenario .stamp { color: var(--accent); font-weight: 700; }
.scenario .arrow { color: var(--rule-strong); }
.scenario .ok { color: var(--green); font-weight: 700; }
.scenario .stale { color: var(--accent); font-weight: 700; }
.scenario .add { color: var(--green); font-weight: 700; }
.scenario .del { color: var(--accent); font-weight: 700; }
/* ---------- TL;DR ---------- */
.tldr {
margin: 1rem 0 0;
padding: 1.5rem 1.75rem;
background: var(--ink);
color: var(--paper-bright);
}
.tldr p {
margin: 0 0 .75em;
font-size: .98rem;
line-height: 1.6;
color: var(--paper-bright);
}
.tldr p:last-child { margin-bottom: 0; }
.tldr p strong { color: #E5C9B5; font-weight: 600; }
/* ---------- Glossary ---------- */
.glossary {
margin: 1.4rem 0;
border-top: 1.5px solid var(--ink);
border-bottom: 1.5px solid var(--ink);
}
.glossary dl {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 1.5rem;
row-gap: 0;
}
.glossary dt {
font-family: 'JetBrains Mono', monospace;
font-size: .82rem;
color: var(--accent-deep);
font-weight: 500;
padding: .7rem .8rem .7rem 0;
border-bottom: 1px solid var(--rule);
align-self: start;
}
.glossary dd {
margin: 0;
padding: .7rem 0;
font-size: .9rem;
line-height: 1.5;
color: var(--ink-soft);
border-bottom: 1px solid var(--rule);
}
.glossary dl > dt:last-of-type,
.glossary dl > dd:last-of-type { border-bottom: 0; }
@media (max-width: 600px) {
.glossary dl { grid-template-columns: 1fr; }
.glossary dt { padding-bottom: .15rem; border-bottom: 0; }
.glossary dd { padding-top: 0; padding-bottom: .8rem; }
}
/* ---------- Footer ---------- */
footer.colophon {
margin-top: 4rem;
padding-top: 1.1rem;
border-top: 1px solid var(--ink);
display: flex;
justify-content: space-between;
font-size: .68rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ink-light);
font-weight: 500;
}
/* Print */
@media print {
body { background: white; font-size: 11pt; }
body::before { display: none; }
.tldr { background: white; color: var(--ink); border: 1px solid var(--ink); }
.tldr p, .tldr p strong { color: var(--ink); }
.doc { width: auto; padding: 0 1.5cm; }
section, .pillars { break-inside: avoid; }
}
@media (max-width: 540px) {
body { font-size: 16px; }
.hero h1 { font-size: 1.65rem; }
.pillars { grid-template-columns: 1fr; }
}
/* ---------- Right-rail TOC (desktop) ---------- */
.toc {
position: fixed;
top: 50%;
right: 1.5rem;
transform: translateY(-50%);
width: 12.5rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
padding: 1rem 1.1rem;
background: var(--paper-bright);
border: 1px solid var(--rule);
border-left: 2px solid var(--accent);
z-index: 50;
font-size: .78rem;
line-height: 1.4;
}
.toc .toc-label {
font-family: 'Source Serif 4', serif;
font-size: .6rem;
letter-spacing: .2em;
text-transform: uppercase;
color: var(--ink-light);
font-weight: 600;
margin-bottom: .55rem;
padding-bottom: .45rem;
border-bottom: 1px solid var(--rule);
}
.toc ol { list-style: none; padding: 0; margin: 0; counter-reset: toc; }
.toc ol li {
counter-increment: toc;
position: relative;
padding: .35rem 0 .35rem 1.5rem;
border-top: 1px solid var(--rule);
}
.toc ol li:first-child { border-top: 0; padding-top: .15rem; }
.toc ol li::before {
content: counter(toc, upper-roman) ".";
position: absolute;
left: 0;
top: .35rem;
font-family: 'Source Serif 4', serif;
font-weight: 600;
font-size: .7rem;
color: var(--accent);
}
.toc ol li:first-child::before { top: .15rem; }
.toc a { color: var(--ink-soft); text-decoration: none; display: block; }
.toc a:hover { color: var(--accent); }
@media (max-width: 1380px) { .toc { display: none; } }
/* ---------- Inline mini-TOC (mobile / narrow) ---------- */
.toc-inline {
display: none;
margin: 1.5rem 0 0;
padding: .9rem 1.1rem;
background: var(--paper-bright);
border: 1px solid var(--rule);
border-left: 2px solid var(--accent);
font-size: .82rem;
}
.toc-inline .toc-label {
font-family: 'Source Serif 4', serif;
font-size: .6rem;
letter-spacing: .2em;
text-transform: uppercase;
color: var(--ink-light);
font-weight: 600;
margin-bottom: .5rem;
}
.toc-inline ol { list-style: none; padding: 0; margin: 0; column-count: 2; column-gap: 1rem; }
.toc-inline ol li { break-inside: avoid; padding: .15rem 0; }
.toc-inline a { color: var(--ink-soft); text-decoration: none; }
.toc-inline a:hover { color: var(--accent); }
@media (max-width: 1380px) { .toc-inline { display: block; } }
@media (max-width: 480px) { .toc-inline ol { column-count: 1; } }
/* ---------- Back-to-top floater ---------- */
.top-link {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
z-index: 60;
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .45rem .75rem .5rem;
background: var(--ink);
color: var(--paper-bright);
text-decoration: none;
font-family: 'Source Serif 4', serif;
font-size: .68rem;
letter-spacing: .14em;
text-transform: uppercase;
font-weight: 600;
border: 0;
box-shadow: 0 6px 18px -8px rgba(30,26,20,.45);
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
}
body.scrolled .top-link { opacity: 1; pointer-events: auto; }
.top-link:hover { background: var(--accent); color: var(--paper-bright); }
@media print { .top-link, .toc, .toc-inline { display: none !important; } }
</style>
</head>
<body id="top">
<!-- ─────────── Right-rail TOC ─────────── -->
<nav class="toc" aria-label="Sections">
<div class="toc-label">On this page</div>
<ol>
<li><a href="#s1">The four systems</a></li>
<li><a href="#s2">Configuration to runtime</a></li>
<li><a href="#s3">User to file list</a></li>
<li><a href="#s4">Group resolution rules</a></li>
<li><a href="#s5">BPMN stamping at task creation</a></li>
<li><a href="#s6">Identity-link tables — runtime & history</a></li>
<li><a href="#s7">Diagnostic checklist</a></li>
<li><a href="#s8">Realm roles that matter</a></li>
<li><a href="#s9">Glossary</a></li>
<li><a href="#s10">Common wrong assumptions</a></li>
<li><a href="#s11">Version compatibility</a></li>
<li><a href="#s12">In one paragraph</a></li>
</ol>
</nav>
<a href="#top" class="top-link" aria-label="Back to top">↑ Top</a>
<div class="doc">
<!-- ─────────── Masthead ─────────── -->
<header class="masthead">
<div>Reference · How Access Control Works</div>
<div class="right">2026‑05‑07</div>
</header>
<!-- ─────────── Hero ─────────── -->
<section class="hero">
<h1>Access control in eRegistrations — identity, configuration, runtime, presentation.</h1>
<p class="standfirst">How Keycloak, BPA, Camunda, and DS together decide what a user sees, why
configuration changes do not propagate to running work, and how to migrate state safely when
they need to.</p>
<dl class="meta">
<dt>Scope</dt>
<dd>Identity, role binding, runtime identity links, file-list filtering</dd>
<dt>Systems</dt>
<dd>Keycloak, BPA, Camunda, DS</dd>
<dt>Tables of interest</dt>
<dd>act_ru_identitylink, act_hi_identitylink</dd>
</dl>
<nav class="toc-inline" aria-label="Sections (mobile)">
<div class="toc-label">Contents</div>
<ol>
<li>I. <a href="#s1">The four systems</a></li>
<li>II. <a href="#s2">Configuration to runtime</a></li>
<li>III. <a href="#s3">User to file list</a></li>
<li>IV. <a href="#s4">Group resolution rules</a></li>
<li>V. <a href="#s5">BPMN stamping at task creation</a></li>
<li>VI. <a href="#s6">Identity-link tables</a></li>
<li>VII. <a href="#s7">Diagnostic checklist</a></li>
<li>VIII. <a href="#s8">Realm roles that matter</a></li>
<li>IX. <a href="#s9">Glossary</a></li>
<li>X. <a href="#s10">Common wrong assumptions</a></li>
<li>XI. <a href="#s11">Version compatibility</a></li>
<li>XII. <a href="#s12">In one paragraph</a></li>
</ol>
</nav>
</section>
<!-- ═══════════════ §1 ═══════════════ -->
<section aria-labelledby="s1">
<header class="section-head">
<div class="label">Section I</div>
<h2 id="s1">The four systems — what each holds</h2>
</header>
<p>Access in eRegistrations is decided by a chain of four systems. Each holds one piece of the
picture; none holds the whole. Data moves left to right: identity flows into configuration,
configuration is stamped onto runtime state at task creation, runtime state is read back into
presentation when a user opens a screen.</p>
<div class="pillars" aria-label="The four systems and direction of data flow">
<div class="pillar">
<div class="stage">Identity</div>
<div class="name">Keycloak</div>
<div class="qa">Who the user is.</div>
<ul>
<li>Users and credentials</li>
<li>Realm roles (<code>partb</code>, <code>super_mario</code>…)</li>
<li>Group hierarchy <code>/institutions/<inst>/<unit></code></li>
<li>JWT with <code>groups</code> + <code>roles</code> claims</li>
</ul>
</div>
<div class="pillar">
<div class="stage">Configuration</div>
<div class="name">BPA</div>
<div class="qa">What is allowed in theory.</div>
<ul>
<li>Services and their roles</li>
<li>Each role's <code>(institution_id, unit_id)</code> binding</li>
<li>Publishing writes a BPMN definition to Camunda</li>
</ul>
</div>
<div class="pillar">
<div class="stage">Runtime</div>
<div class="name">Camunda</div>
<div class="qa">What is alive right now.</div>
<ul>
<li>Process definitions and instances</li>
<li>Tasks with identity links</li>
<li><code>act_ru_identitylink</code> (live)</li>
<li><code>act_hi_identitylink</code> (history)</li>
</ul>
</div>
<div class="pillar">
<div class="stage">Presentation</div>
<div class="name">DS</div>
<div class="qa">What the user is shown.</div>
<ul>
<li>Forwards JWT to BPA backend</li>
<li>Renders menu and file lists</li>
<li>Calls BPA's <code>process/list/</code> extension</li>
</ul>
</div>
</div>
<p>The crucial observation is that <strong>Camunda is the only system that actually decides what a
user can act on at runtime</strong>, because it holds the live identity links of every running
task. BPA <em>tells</em> Camunda what those identity links should be, but only at the moment a
process starts. After that point, configuration changes do not propagate to running work.</p>
<h3>Group structure in Keycloak</h3>
<p>Each institution is a group; each unit inside it is a sub-group. Users are placed in a unit (a
leaf), not in the institution itself.</p>
<div class="tree" role="img" aria-label="Two-level institution group tree">
<div><span class="root">/institutions/</span></div>
<div> └── <span class="inst">Kenya Investment Authority</span><span class="annot">— institution, id <code>cc02519c…</code></span></div>
<div> ├── <span class="unit">CEO</span><span class="annot">— unit (leaf)</span></div>
<div> ├── <span class="unit">Investor Services</span><span class="annot">— unit, id <code>440a9b6e…</code></span></div>
<div> ├── <span class="unit">Bank Review, Registrar, Enquiry, …</span></div>
</div>
<p>The names above are illustrative. The hinge for everything else in this document is the
distinction between a role bound to <em>(institution, no unit)</em> — candidate group equals the
institution UUID — and a role bound to <em>(institution, unit)</em> — candidate group equals the
unit UUID.</p>
</section>
<!-- ═══════════════ §2 ═══════════════ -->
<section aria-labelledby="s2">
<header class="section-head">
<div class="label">Section II</div>
<h2 id="s2">Flow A — Configuration to runtime</h2>
</header>
<p>An admin defines a service in BPA, attaches roles, and for each role chooses an institution and
optionally a unit. Publishing the service writes a BPMN definition into Camunda. The candidate
group attached to each user task in that BPMN is the UUID dictated by the role's binding —
institution UUID if the unit field was left blank, unit UUID otherwise.</p>
<p>When a <em>new</em> process instance starts, Camunda creates each task as the flow reaches it
and <strong>stamps the candidate groups onto the task at task creation</strong>. The stamp is
written into both <code>act_ru_identitylink</code> (live) and <code>act_hi_identitylink</code>
(history). After that point, Camunda does not re-read BPA for that process. The task's
candidate set is fixed at birth.</p>
<p>Concretely:</p>
<ul>
<li>If BPA says role X = <em>(KIA, Investor Services unit)</em>, the candidate group stamped on
every task of role X is the unit UUID <code>440a9b6e…</code>.</li>
<li>If BPA says role Y = <em>(KIA, no unit)</em>, the candidate group stamped on every task of
role Y is the institution UUID <code>cc02519c…</code>.</li>
</ul>
<p>The choice between institution-level and unit-level binding is the one configuration decision
that decides the breadth of every downstream filter. A user matches an institution-level
candidate by virtue of being in <em>any</em> sub-group of that institution; they match a
unit-level candidate only if they are placed directly in that unit.</p>
</section>
<!-- ═══════════════ §3 ═══════════════ -->
<section aria-labelledby="s3">
<header class="section-head">
<div class="label">Section III</div>
<h2 id="s3">Flow B — User to file list</h2>
</header>
<p>DS renders a user's file list by calling BPA's <code>process/list/</code> REST extension,
forwarding the user's JWT. BPA's SQL filters processes and tasks by joining
<code>act_hi_identitylink</code> against the user's effective groups (resolved per §IV).
Two query paths exist.</p>
<h3>Path 1 — <code>filterByTaskPermissions</code></h3>
<p>Applied to every request from a user without a global-bypass realm role —
<code>Camunda/src/main/java/.../TaskRepository.java:80-100</code>:</p>
<pre><code>SELECT i.task_id_
FROM act_hi_identitylink AS i
WHERE i.proc_def_key_ = ?
AND i.type_ = 'candidate'
AND i.group_id_ IN (<user's effective groups>)</code></pre>
<p>A process passes the filter if any of its tasks has a candidate-type identity-link row whose
<code>group_id_</code> is in the user's effective group set. The set is the one Camunda's
authentication layer produces (§IV), not the raw JWT claim.</p>
<h3>Path 2 — institution-filter JOIN</h3>
<p>Added only when DS forwards an explicit <code>?institution=…</code> query parameter — at
<code>Camunda/src/main/java/.../ProcessRepository.java:146-148</code>. Same JOIN shape, scoped
to a single institution UUID. DS sends this parameter only when the user has selected an
institution filter in the UI; in the default file list, only Path 1 is used.</p>
<h3>Global-bypass roles</h3>
<p>A user with any of <code>super_mario</code>, <code>superinspector</code>, <code>sysadmin</code>,
or <code>bot</code> is treated as having global access. <code>filterByTaskPermissions</code> is
skipped entirely; the user sees every process for the service. See §VIII for what each role
grants.</p>
<h3>Reads from history, not runtime</h3>
<p>Both paths join against <code>act_hi_identitylink</code>, which records every identity-link
event a task has ever had (see §VI). They do not read <code>act_ru_identitylink</code>. This
affects how state-altering operations on running tasks propagate to the file list — described
in §VI.</p>
</section>
<!-- ═══════════════ §4 ═══════════════ -->
<section aria-labelledby="s4">
<header class="section-head">
<div class="label">Section IV</div>
<h2 id="s4">Group resolution rules</h2>
</header>
<p>The Display System and the BPA-published Camunda extension resolve a user's groups
<em>differently and intentionally</em>. The two resolutions drive two different decisions and
follow two different rules.</p>
<h3>The two paths</h3>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:13%">System</th>
<th style="width:30%">Source</th>
<th style="width:30%">Rule</th>
<th>Drives</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>DS</strong></td>
<td><code>ds-backend/utils/auth_service.py:980-1004</code>
(<code>AuthKeycloakService.get_user_institutions</code>)</td>
<td>Projects every sub-group membership <em>up</em> to its parent institution UUID.
Sub-group members appear as institution members.</td>
<td>Services dropdown filter (the list of services a user can pick from)</td>
</tr>
<tr>
<td><strong>Camunda</strong></td>
<td><code>Camunda/src/main/java/.../KeycloakAuthServiceClient.java :: getAllSubGroups</code>
+ <code>UserRepository.java:117-127</code></td>
<td>Walks <em>down</em> only. A user directly in an institution gets the institution and
all its child unit UUIDs. A user in a unit only gets that unit's UUID — no parent
institution, no sibling units.</td>
<td>Task-level visibility (the file list shown for a chosen service)</td>
</tr>
</tbody>
</table>
</div>
<h3>The business rule, stated explicitly</h3>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:34%">User's Keycloak placement</th>
<th>What Camunda's group-resolution returns</th>
</tr>
</thead>
<tbody>
<tr>
<td>Member of an <strong>institution</strong> directly (<code>/institutions/<inst></code>)</td>
<td>Institution UUID + every child unit UUID (downward expansion)</td>
</tr>
<tr>
<td>Member of a <strong>unit</strong> only (<code>/institutions/<inst>/<unit></code>)</td>
<td>Unit UUID only — no parent institution, no sibling units</td>
</tr>
<tr>
<td>Member of <strong>both</strong> (institution-direct + a sub-unit)</td>
<td>Institution-direct rule dominates: institution UUID + all unit UUIDs</td>
</tr>
</tbody>
</table>
</div>
<h3>Practical consequence — DS↔Camunda asymmetry</h3>
<p>Because DS projects upward and Camunda does not, a user whose only Keycloak placement is in
a unit can encounter a service in the DS dropdown that contains zero accessible tasks at the
Camunda level. This is the system behaving correctly under both rules; it is not a bug. It
arises whenever a service has roles bound at institution level (so the service surfaces in
DS for any sub-group user of that institution) but no role bound at the user's specific unit
(so Camunda's per-task filter excludes them).</p>
<p>Two ways to eliminate the asymmetry, depending on intent:</p>
<ul>
<li>Bind every BPA role to a specific unit (no <code>unit_id = NULL</code>). Both filters then
tighten consistently and DS's dropdown narrows naturally.</li>
<li>Drop the DS-side projection in <code>auth_service.py:980-1004</code>. Sub-group users see
only services where they hold direct unit-bound roles. <em>Breaking change</em> for
deployments where institution-level role bindings are common.</li>
</ul>
<h3>Worked example</h3>
<div class="tree" role="img" aria-label="Group resolution worked example">
<div>User placement in Keycloak:</div>
<div> <span class="unit">/institutions/<inst>/<unit></span></div>
<div> </div>
<div>DS-side resolution <span class="annot">(get_user_institutions)</span></div>
<div> <span class="root">institutions:</span> { <span class="inst"><inst></span> }</div>
<div> <span class="annot">→ services dropdown shows every service with at least one role bound to <inst> or <unit></span></div>
<div> </div>
<div>Camunda-side resolution <span class="annot">(getAllSubGroups)</span></div>
<div> <span class="root">groups:</span> [ <span class="unit"><unit></span> ]</div>
<div> <span class="annot">→ task list shows only tasks whose candidate group is exactly <unit></span></div>
</div>
</section>
<!-- ═══════════════ §5 ═══════════════ -->
<section aria-labelledby="s5">
<header class="section-head">
<div class="label">Section V</div>
<h2 id="s5">BPMN stamping at task creation</h2>
</header>
<p>Candidate groups are written onto a Camunda task <em>at the moment the task is created</em>,
from the BPMN definition under which the process was started. After that, Camunda does not
re-read the BPA configuration for the rest of the task's life.</p>
<p>Two consequences of this binding model are operationally relevant.</p>
<h3>1. Each process instance is pinned to a <code>processDefinitionId</code> for life</h3>
<p>A republish in BPA produces a new BPMN definition with a new
<code>processDefinitionId</code>. New process instances started after the republish are
associated with the new definition. Existing in-flight process instances stay under their
original definition until they complete; new tasks created inside them inherit the original
definition's candidate groups.</p>
<h3>2. Tightening a role binding takes effect only for new instances</h3>
<p>If a BPA admin changes a role from <em>(institution, no unit)</em> to
<em>(institution, specific unit)</em> and republishes, processes started after the republish
carry the unit-bound candidate group on their tasks. Processes already in flight continue to
carry the institution-level candidate group on their tasks until they complete. The drift is
bounded by the lifetime of the longest in-flight process.</p>
<div class="scenario" aria-label="Stamping behaviour at task creation">
<div><span class="stamp">Process P1 started under definition v1</span> <span class="annot">role bound to (inst, no unit)</span></div>
<div> <span class="arrow">→</span> each task in P1 stamped with candidate = <strong>inst UUID</strong></div>
<br>
<div><span class="stamp">Republish</span> <span class="annot">role tightened to (inst, unit)</span></div>
<br>
<div><span class="stamp">Process P2 started under definition v2</span></div>
<div> <span class="arrow">→</span> each task in P2 stamped with candidate = <strong>unit UUID</strong></div>
<div> </div>
<div><span class="stamp">P1 still in flight</span></div>
<div> <span class="arrow">→</span> new tasks created inside P1 are still stamped from definition v1, i.e. <strong>inst UUID</strong></div>
</div>
<p>This is Camunda's design — it makes process state reproducible and migration explicit. To
affect already-stamped tasks, an explicit identity-link API call against Camunda is required;
see §VII.</p>
</section>
<!-- ═══════════════ §6 ═══════════════ -->
<section aria-labelledby="s6">
<header class="section-head">
<div class="label">Section VI</div>
<h2 id="s6">Identity-link tables — runtime & history</h2>
</header>
<p>Camunda holds identity links — the rows that associate a task with a user or group of a given
type (<code>candidate</code>, <code>assignee</code>, <code>owner</code>, …) — in two tables:</p>
<h3><code>act_ru_identitylink</code> <span class="annot">(runtime)</span></h3>
<p>The set of identity links currently active on a running task. Used by Camunda for claim,
assignment, completion, and the candidate-group authorisation enforced by Camunda's own task
APIs. When a task completes, its rows are removed from this table.</p>
<h3><code>act_hi_identitylink</code> <span class="annot">(history)</span></h3>
<p>The append-only event log of every identity-link operation a task has ever had. Each row
carries an <code>operation_type_</code> of <code>add</code> or <code>delete</code>. Rows are
not removed when a task completes; they remain in the table until Camunda's history-TTL
cleanup runs.</p>
<p>Schema:
<code>id_, timestamp_, type_, user_id_, group_id_, task_id_, root_proc_inst_id_, proc_def_id_,
operation_type_, assigner_id_, proc_def_key_, tenant_id_, removal_time_</code>.</p>
<p>The <code>removal_time_</code> column is reserved for the history-TTL cleanup mechanism. It is
<em>not</em> set when an identity link is deleted at runtime — runtime deletion is recorded as
a separate history row with <code>operation_type_='delete'</code>, leaving the original
<code>add</code> row with <code>removal_time_ = NULL</code>.</p>
<h3>Where each table is read</h3>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:36%">Caller</th>
<th style="width:20%">Reads</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td>Camunda task APIs (claim, assign, complete)</td>
<td><code>act_ru_identitylink</code></td>
<td>Authorise the operation against the task's currently-active candidates.</td>
</tr>
<tr>
<td>BPA <code>filterByTaskPermissions</code> (§III, path 1)</td>
<td><code>act_hi_identitylink</code></td>
<td>Decide which tasks appear in the file list. Filters by <code>type_='candidate'</code> and <code>group_id_ IN (…)</code>; does not filter by <code>operation_type_</code> or <code>removal_time_</code>.</td>
</tr>
<tr>
<td>BPA <code>process/list/</code> institution JOIN (§III, path 2)</td>
<td><code>act_hi_identitylink</code></td>
<td>Same shape, scoped to a single institution UUID. Adds <code>removal_time_ IS NULL</code> to the JOIN.</td>
</tr>
</tbody>
</table>
</div>
<p>Two practical implications follow from "BPA filters read history":</p>
<ul>
<li>A task whose <em>runtime</em> candidate set has been changed via Camunda's task
identity-link API (e.g. an admin removing the old candidate group and adding a new one)
will continue to match the BPA filter for any group that ever appeared as a candidate in
its history. Both the original <code>add</code> row and the new <code>delete</code> row
satisfy <code>type_='candidate' AND group_id_=<old></code>.</li>
<li>This matters mostly when a deployment has had to apply runtime identity-link surgery
(rare). For normal operations — task creation, assignment, completion, BPA republishes —
the runtime and history tables stay in sync as Camunda intends.</li>
</ul>
</section>
<!-- ═══════════════ §7 ═══════════════ -->
<section aria-labelledby="s7">
<header class="section-head">
<div class="label">Section VII</div>
<h2 id="s7">Diagnostic checklist</h2>
</header>
<p>When access is wrong — a user reports they can see something they should not, or cannot see
something they should — work through these checks in order. The first three are read-only and
cheap; later ones touch state and need explicit confirmation.</p>
<h3>Symptom A — User sees tasks they should not</h3>
<ol>
<li><strong>Check the user's realm roles.</strong> Any of <code>super_mario</code>,
<code>superinspector</code>, <code>sysadmin</code>, <code>bot</code>, or
<code>ds_view_all_applications</code> grants global access (§VIII). The
<code>filterByTaskPermissions</code> path is bypassed entirely. If the user has one,
this is expected behaviour, not a bug.</li>
<li><strong>Check the user's effective Camunda groups.</strong> Camunda computes them per
§IV — units only, no upward projection (assuming a fixed Camunda version, §XI). If groups
contain a parent institution UUID and the Keycloak placement is unit-only, the deployment
is on a Camunda image whose <code>getAllSubGroups</code> projects upward (see §XI for which
versions are exposed).</li>
<li><strong>Check the role bindings on the service in BPA.</strong> Roles bound to
<em>(institution, no unit)</em> are visible to any user whose effective groups include
that institution UUID. If the intent is to restrict to a specific unit, the BPA role
binding must name that unit.</li>
<li><strong>Check for state already created under an older binding.</strong> If a role's BPA
binding has been tightened recently (§V), processes started before the change carry the
older candidate group on their tasks for the rest of their lifetime. The drift is bounded
and resolves as in-flight processes complete.</li>
</ol>
<h3>Symptom B — User cannot see tasks they should</h3>
<ol>
<li><strong>Check the user's Keycloak group memberships</strong> via the admin REST endpoint
<code>/admin/realms/<realm>/users/<id>/groups</code>. The user must be a direct
member of either the institution or the relevant unit group.</li>
<li><strong>Check the BPA role binding for the task definition</strong> the user expects to
see. If the role is bound to <em>(institution, no unit)</em>, only institution-direct
members see it, not unit-only members (§IV business rule).</li>
<li><strong>Check the candidate group recorded on the task.</strong> Camunda's
<code>/task/<id>/identity-links</code> endpoint reports the live candidates. They
should match the published BPA binding. If they reference an older group, the task was
created under a previous BPMN definition (§V); only completion or runtime migration
changes that.</li>
<li><strong>Check <code>AUTH_SERVICE_INSTITUTION_GROUP_ID</code></strong> on the Camunda
container. If misconfigured, group hierarchy resolution returns a degenerate set.
Misconfiguration looks like every user reporting "I cannot see anything", not like a
user-specific issue.</li>
</ol>
<h3>Symptom C — Service appears in the dropdown but contains zero tasks</h3>
<p>This is the DS↔Camunda asymmetry described in §IV. The DS-side projection surfaces the
service for any sub-group user of the parent institution; Camunda's task-level filter
restricts to the user's exact unit. If no role on the service is bound to the user's unit,
the file list is correctly empty. To eliminate the asymmetry, either bind the relevant role
to the user's unit in BPA, or follow the breaking-change path noted in §IV.</p>
<h3>Read-only commands worth running first</h3>
<p>Before any state-altering action:</p>
<ul>
<li>The Camunda image label on the host:
<code>docker inspect <container> --format '{{.Config.Image}}'</code> — confirm the
version is on the §XI compatibility list.</li>
<li>The user's Keycloak groups (admin REST, as above).</li>
<li>The user's realm roles
(<code>/admin/realms/<realm>/users/<id>/role-mappings/realm</code>).</li>
<li>The candidate set on a representative task
(<code>/task/<id>/identity-links</code> on the Camunda REST).</li>
<li>The BPA role binding for the task's definition key (<code>BPA service_get</code> or
<code>role_list</code>).</li>
</ul>
<p>State-altering steps (BPMN republish, runtime identity-link migration, SQL surgery on
<code>act_hi_identitylink</code>) are deployment-specific remediations and are out of scope
for this reference.</p>
</section>
<!-- ═══════════════ §8 ═══════════════ -->
<section aria-labelledby="s8">
<header class="section-head">
<div class="label">Section VIII</div>
<h2 id="s8">The Keycloak realm roles that actually matter</h2>
</header>
<p>The realm typically declares a dozen roles. Only a handful are consulted by code — most are
documentation, latent capabilities, or vestiges. The list below covers what is actually
referenced.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:22%">Role</th>
<th style="width:30%">Where consulted</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>partb</code></td>
<td><code>ds-backend/apps/profile/views.py:282-288</code><br><em>(<code>_should_redirect_to_part_b</code>)</em></td>
<td>Post-login redirect target. Users with this role land on <code>/{lang}/part-b</code>; users without it land on <code>/</code>. The <em>only</em> role consulted by code.</td>
</tr>
<tr>
<td><code>super_mario</code></td>
<td>BPA Camunda <code>hasGlobalAccess()</code></td>
<td>Global access. Bypasses <code>filterByTaskPermissions</code>; sees every task in every service.</td>
</tr>
<tr>
<td><code>superinspector</code></td>
<td>BPA Camunda <code>hasGlobalAccess()</code></td>
<td>Same global-access bypass.</td>
</tr>
<tr>
<td><code>sysadmin</code></td>
<td>BPA Camunda <code>hasGlobalAccess()</code>; ds-frontend <code>SysadminGuard</code></td>
<td>Global access in BPA; gates admin routes in ds-frontend.</td>
</tr>
<tr>
<td><code>bot</code></td>
<td>BPA Camunda <code>hasGlobalAccess()</code></td>
<td>Used for service-account / automation actors; same global bypass.</td>
</tr>
<tr>
<td><code>PartA_Registrar</code></td>
<td><strong>Nowhere.</strong> Zero references in ds-frontend or ds-backend.</td>
<td>Declared in the realm with the description "Gives access to DS my account and some services in part A", but is not consulted anywhere in the code. The Part A page has no role guard; the only Part A vs Part B differentiation is the post-login redirect for users with <code>partb</code>.</td>
</tr>
</tbody>
</table>
</div>
<p>Two takeaways. First, the realm role surface relevant to runtime visibility is small: one role
to choose the post-login destination, four roles to opt out of the candidate-group filter
entirely, and one role gating an admin route in the frontend. Second, the absence of a role
guard on Part A is a property of the route table, not of the role catalogue — adding a Part A
guard requires a frontend change, not a Keycloak change.</p>
</section>
<!-- ═══════════════ §9 ═══════════════ -->
<section aria-labelledby="s9">
<header class="section-head">
<div class="label">Section IX</div>
<h2 id="s9">Glossary</h2>
</header>
<div class="glossary">
<dl>
<dt>act_hi_identitylink</dt>
<dd>Camunda history table holding every identity-link event a task has ever had. Indexed for read-heavy queries; consulted by BPA's file-list filters.</dd>
<dt>act_ru_identitylink</dt>
<dd>Camunda runtime table holding the currently-active identity links on running tasks. Used by Camunda for claim, assignment, and completion; not read by BPA's filters.</dd>
<dt>BPMN definition</dt>
<dd>The XML process definition Camunda executes. Written by BPA at publish time and pinned to a <code>processDefinitionId</code>. Republishing creates a new definition; running processes keep their original.</dd>
<dt>candidate group</dt>
<dd>A group UUID stamped onto a Camunda user task at task creation, identifying who is eligible to claim the task. Stored as a row of <code>type_='candidate'</code> in the identity-link tables.</dd>
<dt>filterByTaskPermissions</dt>
<dd>The BPA SQL path at <code>TaskRepository.java:80-100</code> that joins <code>act_hi_identitylink</code> against the user's groups to decide which tasks appear in the file list. Skipped for users with global access.</dd>
<dt>identity link</dt>
<dd>A row associating a Camunda task with a user or group, of a given type (<code>candidate</code>, <code>assignee</code>, <code>owner</code>, …). Lives in <code>act_ru_identitylink</code> at runtime and is mirrored to <code>act_hi_identitylink</code> as history.</dd>
<dt>institution</dt>
<dd>A top-level group under <code>/institutions/</code> in Keycloak. Has a UUID; serves as the candidate group for any role in BPA whose unit field is left blank.</dd>
<dt>operation_type</dt>
<dd>Column on <code>act_hi_identitylink</code> distinguishing <code>add</code> from <code>delete</code> events. Not consulted by BPA's <code>filterByTaskPermissions</code> SQL.</dd>
<dt>processDefinitionId</dt>
<dd>Camunda's identifier for one published version of a BPMN definition. Pinned per process instance for life — running processes keep their original even after republish.</dd>
<dt>removal_time</dt>
<dd>Column on <code>act_hi_identitylink</code> reserved for history-TTL cleanup. Not set when an identity link is deleted at runtime; runtime deletion is recorded as a separate history row with <code>operation_type_='delete'</code>.</dd>
<dt>getAllSubGroups</dt>
<dd>Method in <code>KeycloakAuthServiceClient.java</code> that resolves a Camunda user's effective groups by fetching the institution hierarchy from Keycloak. Walks down only — institution-direct users get all child unit UUIDs; unit-only users keep just their direct memberships. Behaviour requires a fixed Camunda version (§XI).</dd>
<dt>super_mario</dt>
<dd>Realm role granting global access — bypasses <code>filterByTaskPermissions</code>. Same effect as <code>superinspector</code>, <code>sysadmin</code>, and <code>bot</code> from the filter's perspective.</dd>
<dt>unit</dt>
<dd>A sub-group under an institution in Keycloak. Has a UUID; serves as the candidate group when a BPA role binds to a specific unit. On the DS side, unit members are projected up to their parent institution for the services-dropdown filter; on the Camunda side, they are not (§IV).</dd>
<dt>DS↔Camunda asymmetry</dt>
<dd>The intentional difference between DS's upward unit→institution projection (services dropdown) and Camunda's downward-only resolution (task-level filter). A unit-only user can see services in the dropdown that contain zero accessible tasks. See §IV for the rule and §VII Symptom C for diagnosis.</dd>
</dl>
</div>
</section>
<!-- ═══════════════ §10 ═══════════════ -->
<section aria-labelledby="s10">
<header class="section-head">
<div class="label">Section X</div>
<h2 id="s10">Common wrong assumptions</h2>
</header>
<p>Investigations recur to the same misconceptions. Each entry below states a claim that sounds
plausible, the reality, and where to verify against source.</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:38%">Claim</th>
<th style="width:42%">Reality</th>
<th>Anchor</th>
</tr>
</thead>
<tbody>
<tr>
<td>Republishing a service in BPA migrates the candidate groups on its in-flight tasks.</td>
<td>False. New candidate groups apply only to tasks created <em>after</em> the republish. In-flight processes stay under their original BPMN definition for life.</td>
<td>§V</td>
</tr>
<tr>
<td><code>removal_time_</code> is set on <code>act_hi_identitylink</code> rows when their identity link is deleted at runtime.</td>
<td>False. <code>removal_time_</code> is set only by Camunda's history-TTL cleanup. Runtime deletion writes a separate history row with <code>operation_type_='delete'</code>.</td>
<td>§VI</td>
</tr>
<tr>
<td>A user in a Keycloak unit is automatically a member of the parent institution at the Camunda level.</td>
<td>False on a fixed Camunda version (§XI). Camunda walks the hierarchy <em>down</em> only. The unit→institution projection lives on the DS side, and only drives the services-dropdown filter — not task-level visibility.</td>
<td>§IV</td>
</tr>
<tr>
<td>The DS service dropdown and the Camunda task list apply the same filter.</td>
<td>False. They apply two different filters by design — the DS↔Camunda asymmetry. A user can see a service in the dropdown and find zero accessible tasks inside it. §IV gives the rule; §VII Symptom C gives the diagnosis.</td>
<td>§IV, §VII</td>
</tr>
<tr>
<td>The realm role <code>PartA_Registrar</code> gates access to Part A.</td>
<td>False. Zero references to it in <code>ds-frontend</code> or <code>ds-backend</code>. The Part A vs Part B differentiation is a single post-login redirect on the <code>partb</code> realm role at <code>ds-backend/apps/profile/views.py:282-288</code>.</td>
<td>§VIII</td>
</tr>
<tr>
<td>BPA's <code>filterByTaskPermissions</code> SQL is broken because it reads history rather than runtime.</td>
<td>True at the SQL layer (it does read history) but rarely the actionable diagnosis. Given the correct group set produced by §IV, the filter behaves as intended for normal operations. The far more common cause of "I see / don't see X" issues is incorrect group resolution at the JWT or identity-service layer (§IV), or a user holding a global-bypass realm role (§VIII), or BPA role bindings that don't match the deployment's intent.</td>
<td>§III, §IV, §VIII</td>
</tr>
<tr>
<td><code>docker pull <image>:<tag></code> will refresh a deployment to the latest fix.</td>
<td>Only if the tag is floating. Most eRegistrations stack components are pinned to exact patch versions; pulling against a fixed tag is a no-op. To upgrade, edit the tag in the deployment file and redeploy.</td>
<td>§XI</td>
</tr>
<tr>
<td>Bumping the Camunda image tag automatically re-stamps already-running tasks with new candidate groups.</td>
<td>False. Image upgrades change in-process logic (group resolution, SQL queries) but do not modify rows already in the identity-link tables. Existing in-flight tasks keep whatever candidates were stamped at their creation.</td>
<td>§V, §VI</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- ═══════════════ §11 ═══════════════ -->
<section aria-labelledby="s11">
<header class="section-head">
<div class="label">Section XI</div>
<h2 id="s11">Version compatibility</h2>
</header>
<p>The behaviour described in this document — in particular the §IV resolution rules — is
guaranteed only on the following minimum versions:</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:30%">Component</th>
<th style="width:30%">Minimum image / commit</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>unctad/camunda</code> — release/2.17 line</td>
<td><code>2.17.14</code></td>
<td>Earlier 2.17.x patch builds (specifically <code>.11</code>, <code>.12</code>, <code>.13</code>) projected unit memberships up to the parent institution in <code>getAllSubGroups</code>. The fix removes that projection.</td>
</tr>
<tr>
<td><code>unctad/camunda</code> — release/2.18 line</td>
<td><code>2.18.33</code></td>
<td>Same fix backported.</td>
</tr>
<tr>
<td><code>unctad/camunda</code> — release/2.19 line</td>
<td><code>2.19.5</code></td>
<td>Same fix.</td>
</tr>
<tr>
<td><code>ds-backend</code></td>
<td>Any current build</td>
<td>The unit→institution projection in <code>get_user_institutions</code> has been stable since TOBE-11793 (Feb 2023).</td>
</tr>
<tr>
<td><code>bpa-backend</code></td>
<td>Any current build with Keycloak 23+ support</td>
<td>Hierarchy resolution uses the <code>/groups/{id}/children</code> endpoint, compatible with the Keycloak API change in version 23.</td>
</tr>
</tbody>
</table>
</div>
<p>The Keycloak admin REST itself depends on the Keycloak version. From Keycloak 23 onward,
<code>/admin/realms/<realm>/users/<id>/groups</code> no longer includes the
<code>parentId</code> field on returned groups. Code that resolves group hierarchy must use
<code>/groups/<id>/children</code> instead of relying on <code>parentId</code>.</p>
</section>
<!-- ═══════════════ §12 — Closing ═══════════════ -->
<section aria-labelledby="s12">
<header class="section-head">
<div class="label">In one paragraph</div>
<h2 id="s12">The mechanism, condensed</h2>
</header>
<div class="tldr">
<p>Keycloak holds identity (realm roles, group memberships under
<code>/institutions/<inst>/<unit></code>). BPA holds configuration — each role
is bound to an <strong>(institution, unit)</strong> pair, with <code>unit_id = NULL</code>
meaning institution‑wide. At publish time BPA writes the corresponding UUID onto each task
of the BPMN as the <strong>candidate group</strong>. When a process starts, Camunda stamps
that candidate set onto each task and does not re‑read BPA again for that process. DS
forwards the user's JWT to BPA's <code>process/list/</code>, which joins
<code>act_hi_identitylink</code> against the user's effective groups.</p>
<p>Group resolution is asymmetric by design. <strong>DS</strong> projects every sub-group
membership up to the parent institution UUID — this drives the services dropdown.
<strong>Camunda</strong> walks the hierarchy down only — institution-direct users get all
child units; unit-only users get just their direct unit. The intentional consequence: a
unit-only user can see a service in the DS dropdown and find zero accessible tasks inside
it. To eliminate that asymmetry, either bind every BPA role to a specific unit, or remove
the DS-side projection. To investigate access anomalies, work the §VII checklist.</p>
</div>
</section>
<footer class="colophon">
<div>Reference · Access Control</div>
<div>2026‑05‑07</div>
</footer>
</div>
<script>
(function () {
var threshold = 600;
var ticking = false;
function update() {
document.body.classList.toggle('scrolled', window.scrollY > threshold);
ticking = false;
}
window.addEventListener('scroll', function () {
if (!ticking) {
window.requestAnimationFrame(update);
ticking = true;
}
}, { passive: true });
})();
</script>
</body>
</html>