User Access Interface Templates¶
Issue: unitysvc-bridge-ntfy#1 — Restrict users to enrollment-specific topics Related PR: unitysvc#437 — ntfy service integration Date: 2026-02-10 Status: Implemented
Overview¶
String values support Jinja2 template syntax for dynamic rendering on both of the orthogonal axes: user access interfaces (user_access_interfaces, the downstream endpoints customers connect to) and upstream access channels (upstream_access_config, how the gateway reaches the upstream). The timing differs, though: a user access interface template is rendered per enrollment (it produces an enrollment-scoped AccessInterface record), whereas an upstream access channel template is rendered per request at gateway routing time (it resolves the upstream target for that one request and creates no record). Both see the same enrollment context for a given enrollment — for example, generating unique endpoint URLs or routing keys for each subscriber.
Interfaces containing template syntax ({{ or {%) are rendered per-enrollment and create enrollment-scoped AccessInterface records. Static interfaces (no template syntax) are shared across all enrollments at the listing level.
Template Context¶
Templates are rendered with these variables:
| Variable | Type | Description |
|---|---|---|
enrollment.code |
string | The enrollment's unique 4-character reference code |
enrollment.id |
string | Enrollment UUID |
enrollment.customer_id |
string | Customer UUID |
enrollment.parameters |
dict | All enrollment parameters |
enrollment.code and the /e/<code> primitive¶
Every enrollment is assigned a unique, stable 4-character code (Crockford base32, e.g. CEFF) at creation. Reference it in any template with {{ enrollment.code }} — both user_access_interfaces and upstream_access_config see the same value for a given enrollment.
The code is also a built-in routing handle: every enrollment is reachable at /e/<code> (e.g. ${API_GATEWAY_BASE_URL}/e/CEFF), which the gateway resolves to that enrollment's endpoint — regardless of the base_url you define. You don't build /e/... yourself, and /e/ is reserved (you cannot use it in base_url); it is always available for free, as a short, unique handle to the enrollment.
Migration:
enrollment.codereplaces the oldenrollment_code()template function. Use{{ enrollment.code }}instead of{{ enrollment_code(6) }}. The code is now a fixed 4 characters — the length argument is gone.
Example: ntfy Service¶
The ntfy service exposes a notification gateway where each enrollment gets a unique topic code.
Configuration¶
# listing.toml — user-facing endpoint with per-enrollment topic
[user_access_interfaces.ntfy-gateway]
access_method = "http"
base_url = "${API_GATEWAY_BASE_URL}/ntfy/{{ enrollment.code }}"
description = "Your ntfy notification endpoint"
# offering.toml — upstream endpoint with same enrollment code
[upstream_access_config.ntfy-upstream]
access_method = "http"
base_url = "https://ntfy.svcpass.com/{{ enrollment.code }}"
description = "Private ntfy instance"
Both templates reference {{ enrollment.code }} and resolve to the same code (e.g. CEFF) for a given enrollment.
After Enrollment¶
- The enrollment's code (e.g.
CEFF) is generated at enrollment creation - An enrollment-scoped
AccessInterfaceis created withbase_url = "${API_GATEWAY_BASE_URL}/ntfy/CEFF" - The user sees their complete, personalized endpoint
- At gateway routing time, the upstream template resolves to
https://ntfy.svcpass.com/CEFF - Gateway forwards the request to the correct upstream topic
Access Control¶
Enrollment-scoped AccessInterface records are only visible to the enrollment that generated them:
AccessInterface scope |
Who can access | Linked via |
|---|---|---|
| Listing-level (no template) | All enrolled customers | ServiceEnrollment |
Group-scoped (group_id set) |
Customers with GroupEnrollment | GroupEnrollment |
| Enrollment-scoped (from template) | Only that specific enrollment | enrollment_id match |
How It Works¶
User access interfaces (enrollment time)¶
- During enrollment creation or activation, the backend checks
listing.user_access_interfaces - Each interface is classified:
- Template (contains
{{or{%): rendered per-enrollment, creates enrollment-scopedAccessInterface - Static (no template syntax): shared listing-scoped
AccessInterface(idempotent) - Template rendering substitutes
{{ enrollment.code }}(and the otherenrollment.*context values) with the enrollment's data - Rendered values are validated as
AccessInterfaceDataand persisted via upsert
Upstream access interfaces (gateway routing time)¶
- When a request arrives, the gateway identifies the enrollment from the user access interface match
- If the offering's
upstream_access_configcontain template syntax, they are rendered using the enrollment context {{ enrollment.code }}resolves to the enrollment's 4-character code (assigned at enrollment creation)- The resolved upstream URL is used to forward the request — no upstream
AccessInterfacerecords are created per enrollment
Deferring expansion: the raw block¶
The backend Jinja-expands every string in an upstream_access_config channel at routing time (using the enrollment context above). That breaks values that carry their own template syntax meant for a later stage — most often request/response transformers, whose {{ … }} templates are evaluated by the gateway's transformer engine against per-request data, not by the backend against enrollment context. Left alone, the backend's Jinja pass would consume or mangle templates that were never meant for it.
Wrap those values in a raw block. Within a channel the backend:
- Skips the
rawsubtree during Jinja2 expansion — its contents pass through verbatim. - After expanding the rest of the channel, hoists
raw's keys up into the channel (shallow merge) and removes therawkey.
So raw is purely an authoring marker for "don't Jinja-expand this here." It never appears in the resolved config — its contents land at the channel level as if written there directly.
{
"upstream_access_config": {
"managed": {
"access_method": "http",
"base_url": "https://api.example.com/{{ enrollment.code }}",
"api_key": "${ secrets.UPSTREAM_KEY }",
"raw": {
"request_transformer": {
"body": { "model": "{{ model }}" }
}
}
}
}
}
Here base_url is Jinja-expanded by the backend ({{ enrollment.code }} → CEFF), while the transformer's {{ model }} is preserved untouched for the gateway. After processing, the channel resolves to { access_method, base_url: ".../CEFF", api_key: <resolved>, request_transformer: {…verbatim…} } — no raw key.
Important — raw defers Jinja2 only, not secrets. Because raw's contents are merged into the channel before secret resolution, ${ secrets.* } / ${ customer_secrets.* } references inside raw are still resolved. raw skips {{ … }}, not ${ … }.
Scope notes:
- The hoist-and-remove runs per channel (the top level of each upstream_access_config entry). A raw block is preserved verbatim wherever it appears, but only a channel-top-level raw is merged up and dropped.
- raw keys override same-named expanded keys (shallow merge, raw wins).
- Multi-server channels: whether raw is also hoisted inside each servers[] entry is an open build-time question — see unitysvc/unitysvc#1299.
Consistency with Service Groups¶
This mechanism mirrors the existing service group template pattern:
| Aspect | Service Groups | Enrollment Templates |
|---|---|---|
| Template language | Jinja2 | Jinja2 |
| Trigger | Service joins group | User enrolls in service |
| Context | Service metadata | Enrollment + parameters |
| Output | AccessInterfaceData |
AccessInterfaceData |
| Scope link | AccessInterface.group_id |
AccessInterface.entity_id (enrollment) |