Access Control
Olly docs uses scope-based RBAC so individual pages and inline sections can be restricted to specific Keycloak roles, while the bulk of the corpus stays public.
Model
Every viewer has zero or more scopes (Keycloak realm roles). Pages and blocks declare required scopes. A viewer matches if the intersection with required scopes is non-empty (mode: any, default) or covers all (mode: all). The docs-admin scope is a super-scope - it satisfies any check.
| Scope | Grants access to |
|---|---|
docs-admin | Everything. Super-scope; satisfies any check. |
docs-internal | Olly-internal content (this page, internal design docs, raw eval reports). |
partner-health | Pages shared with NHS / health-partner reviewers. |
partner-insurance | Pages shared with reinsurance and underwriting partners. |
partner-clinical-ops | Pages shared with clinical operations partners (triage hint review, MSK pathway evidence). |
How to gate a whole page
Add access: to the page frontmatter:
---
title: My Internal Doc
access:
scopes: [docs-internal]
mode: any
---The page is still emitted into dist/ but nginx refuses to serve it without a matching session. Use the page-gate when the entire page is sensitive - the URL itself is enough of a signal that you don't want the body cached anywhere public.
How to gate a block within an otherwise-public page
Wrap the sensitive content in a <Internal> element. The block content is extracted at build time and never appears in the public static HTML - only a <GatedBlock id="..." /> placeholder remains in the page.
Most of this page is public.
<Internal>
## Internal-only section
This content is sha256'd, written to `/_gated/<id>.html`, and only fetched
after oauth2-proxy confirms the viewer has the required scopes.
</Internal>
Public content continues below.The audit script scripts/audit-gated-leak.sh greps the built dist/ after every build to confirm no inner block text leaked into the public HTML.
How to grant scopes to a user
- Sign in at https://auth.dev.hiolly.com/admin.
- Realm
olly-> Users -> pick user -> Role mapping. - Assign realm role(s) from the table above.
Roles propagate at next sign-in or after the oauth2-proxy session refreshes (max 1h).
Architecture
| Layer | Where it runs | What it does |
|---|---|---|
| Auth gateway | systemd unit oauth2-proxy-docs on dev-1 | Validates the session cookie against Keycloak, exposes /oauth2/auth for nginx subrequests, serves userinfo at /_user.json. |
| Cookie domain | .dev.hiolly.com | Single sign-on across all *.dev.hiolly.com hosts. |
| nginx | /etc/nginx/sites-enabled/olly | auth_request /oauth2/auth?allowed_groups=... per path. Per-page scope map at /var/www/docs.dev.hiolly.com/_access/page-scopes.conf, regenerated on every build. |
| Gated blocks | markdown-it plugin gated-block.ts | Writes /var/www/docs.dev.hiolly.com/_gated/<sha256>.html at build time. nginx serves with the same auth_request pattern. |
| Sidebar filter | SidebarFilter component | Fetches /_user.json (oauth2-proxy userinfo) and hides items the viewer can't access. |
| Login UI | LoginButton in the nav | Anonymous "Sign in" pill, signed-in email pill with sign-out dropdown. |
| 403 page | /403.html (static, public) | Shown when a signed-in user lacks the required scope. |
Defense-in-depth: every layer enforces scopes independently. The sidebar filter only hides links - it never gates content. nginx + oauth2-proxy is the source of truth.
Operational notes
- Rotate the docs-auth client secret in Keycloak quarterly. Update
/etc/oauth2-proxy/docs-client-secretandsystemctl restart oauth2-proxy-docs. - Cookie secret in
/etc/oauth2-proxy/cookie-secretrotates annually. Rotating invalidates all live sessions. - nginx access maps regenerate on every
npm run buildand live under/var/www/docs.dev.hiolly.com/_access/. If a scope check looks wrong, check the generatedpage-scopes.conffirst - it pins the build-time view of frontmatter.
SSO across all olly hosts
cookie_domain=.dev.hiolly.com means signing in at any olly host (member portal, employer portal, admin console, docs) gives a session at docs too. This is intentional SSO. Tighten in /etc/oauth2-proxy/docs.cfg if you don't want it.
