--- title: "DD-0005: AAI → Waldur User Sync — Implementation" --- # DD-0005: AAI → Waldur User Sync — Implementation **Related ADR:** [ADR-0005: AAI → Waldur User Synchronization](/adr/0005-aai-waldur-user-sync) **Status:** Draft **Date:** 2026-05-19 **Authors:** TBD ## Overview On every AAI login, Themelio's NextAuth `signIn` callback (in `services/web/src/auth.ts`) creates or updates the user in Waldur via the idempotent Identity Bridge using a Waldur staff token. Waldur project invitation acceptance is handled separately: users arrive via a dedicated invite link (`/?waldur_invite=true`), which routes them through login and then to a dedicated API route that fetches and accepts their pending invitations. Failures in either flow never block login. ## Goals and Non-Goals ### Goals - Propagate AAI user attributes to Waldur idempotently on every login via the Identity Bridge API. - Auto-accept pending Waldur project invitations when the user arrives via an invite link (`/?waldur_invite=true`), including when they need to log in first. - Record Waldur sync failures durably in Valkey and expose them via an admin endpoint for manual remediation. - Keep Waldur entirely invisible to end users — they never log into Waldur directly. ### Non-Goals - Periodic background sync — no CronJob or polling service. - User deactivation or deletion propagation to Waldur (v1 out of scope). - Replacing the manual Waldur admin step of creating projects and invitations. - Terms & Conditions acceptance (tracked separately in ADR-0005 Q10). - Invitation email customization (tracked in ADR-0005 Q9). ## Design ### 1. Environment Configuration New variables added to `services/web/.env.example` and to the Kubernetes `Secret/themelio-web-env` in each environment overlay: | Variable | Description | |---|---| | `WALDUR_API_URL` | Waldur API base URL, e.g. `https://dev.waldur.pharos.grnet.gr` | | `WALDUR_STAFF_TOKEN` | API token of a Waldur account with staff / identity-manager role. In Ansible this is generated by `waldur-offering` and stored in `Secret/themelio-waldur-credentials`. | | `WALDUR_IDENTITY_SOURCE` | Waldur identity source identifier sent as `source` to Identity Bridge, e.g. `isd:pharos` | If `WALDUR_API_URL` is unset, the entire Waldur sync block is skipped at runtime. When `WALDUR_API_URL` is set, `WALDUR_STAFF_TOKEN` and `WALDUR_IDENTITY_SOURCE` are required. This keeps the feature inert in local development until Waldur credentials are configured. ### 2. Waldur Utility Module New file: `services/web/src/lib/waldur.ts` **`syncUserToWaldur(user)`** — calls `POST /api/identity-bridge/` with `source` set from `WALDUR_IDENTITY_SOURCE`. Throws on non-2xx responses. **`acceptPendingInvitations(email)`** — calls `GET /api/user-invitations/?email_exact={email}&state=pending&page_size=200`, follows `next` pagination links to collect all pages, then calls `POST /api/user-invitations/{uuid}/accept/` for each result. Throws on non-2xx responses. Called from the invite-link API route (section 4), not from the `signIn` callback. This accept call must be validated for the server-side auto-accept flow because Waldur documents it as accepting invitations for the currently authenticated token user. **`writeWaldurSyncFailure({ email, username, step, invitationUuid?, error })`** — serialises a failure record and writes it to Valkey (see section 7). ### 3. NextAuth signIn Callback Changes File: `services/web/src/auth.ts` The existing `signIn` callback (line 68) wraps GitLab provisioning in a `try/catch` that returns `false` on error, blocking login. Waldur sync uses a **separate inner `try/catch`** so failures are recorded but never prevent login. The following block is inserted after `await setSessionCookie(...)` (line 88) and before `return true`. It handles only Identity Bridge — invitation acceptance is handled separately via the invite-link route (see section 4): ```ts if (process.env.WALDUR_API_URL) { const email = profile?.email as string const firstName = (profile?.given_name ?? '') as string const lastName = (profile?.family_name ?? '') as string const org = profile?.schac_home_organization as string | undefined try { await syncUserToWaldur({ username, email, firstName, lastName, organization: org }) } catch (err) { await writeWaldurSyncFailure({ email, username, step: 'identity-bridge', error: err }) } } ``` OIDC scopes already include `schac_home_organization` (see `auth.ts` line 42). ### 4. Invite Link Flow Waldur project invitations are accepted via an explicit invite link rather than on every login. This avoids unnecessary Waldur API calls for users who have no pending invitations and makes the trigger explicit. #### URL Entry Point A Waldur admin sends the user a link to `https:///?waldur_invite=true`. The user may or may not already be logged in. #### Flow ``` User opens /?waldur_invite=true │ ▼ (public)/page.tsx detects searchParam │ ├─ already logged in? ──► redirect('/api/waldur/accept-invitations') │ └─ not logged in? │ ▼ SignInButtons renders with waldurInvite=true │ ▼ (click) signIn('openid_connect', { callbackUrl: '/api/waldur/accept-invitations' }) │ ▼ Keycloak login ──► NextAuth callback ──► redirect to callbackUrl │ ▼ GET /api/waldur/accept-invitations │ ├─ verify session (if missing → redirect to /?waldur_invite=true) ├─ try: │ acceptPendingInvitations(user.email) └─ catch: writeWaldurSyncFailure(…) — do not rethrow │ ▼ redirect('/dashboard') ``` #### Files Changed **`services/web/src/app/(public)/page.tsx`** (server component) - Read `searchParams.waldur_invite` - If `'true'` and `auth()` returns a session → `redirect('/api/waldur/accept-invitations')` - Otherwise pass `waldurInvite: boolean` prop to `SignInButtons` **`services/web/src/components/SignInButtons.tsx`** - Accept optional prop `waldurInvite?: boolean` - When `true`, call `signIn('openid_connect', { callbackUrl: '/api/waldur/accept-invitations' })` instead of plain `signIn('openid_connect')` **`services/web/src/app/api/waldur/accept-invitations/route.ts`** *(new file)* - `GET` handler - Call `auth()` — if no session, `redirect('/?waldur_invite=true')` - `try { await acceptPendingInvitations(session.user.email) }` - `catch (err) { await writeWaldurSyncFailure({ email, step: 'fetch-invitations', error: err }) }` — never rethrow - Always: `redirect('/dashboard')` ### 5. Attribute Mapping OIDC claim → Waldur Identity Bridge field: | OIDC claim | Identity Bridge field | Notes | |---|---|---| | `preferred_username` | `username` | Falls back to `sub` if absent | | `WALDUR_IDENTITY_SOURCE` | `source` | Required; expected Pharos value: `isd:pharos` | | `email` | `email` | Required | | `given_name` | `first_name` | | | `family_name` | `last_name` | | | `schac_home_organization` | `organization` | Optional; omit if absent | ### 6. API Call Specifications #### Identity Manager Setup The `WALDUR_STAFF_TOKEN` used by Themelio is generated by `waldur-offering` and stored in `Secret/themelio-waldur-credentials`. It belongs to the `waldur-site-agent` automation user, which is configured as staff, an identity manager, and an offering manager. For Identity Bridge calls to succeed, that user must have `is_identity_manager: true` and the AAI source listed in `managed_isds`. Configure this with a Waldur admin token: ``` GET {WALDUR_API_URL}/api/users/?username=&field=uuid&field=username Authorization: Token ``` Use the returned `uuid` to patch the user: ``` PATCH {WALDUR_API_URL}/api/users/{uuid}/ Authorization: Token Content-Type: application/json { "is_identity_manager": true, "managed_isds": ["isd:pharos"] } ``` The exact `managed_isds` value must match the identity source configured in the target Waldur environment. #### Identity Bridge ``` POST {WALDUR_API_URL}/api/identity-bridge/ Authorization: Token {WALDUR_STAFF_TOKEN} Content-Type: application/json { "username": "jdoe", "source": "isd:pharos", "email": "jdoe@example.com", "first_name": "Jane", "last_name": "Doe", "organization": "Example University" // optional } ``` Expected: `200 OK` (existing user updated) or `201 Created` (new user). The response body is not used. Throws on any other status. #### Fetch Pending Invitations ``` GET {WALDUR_API_URL}/api/user-invitations/?email_exact={email}&state=pending&page_size=200 Authorization: Token {WALDUR_STAFF_TOKEN} ``` Expected: `200 OK` with paginated response: ```json { "count": 2, "next": "...?page=2", "results": [ { "uuid": "abc123", "email": "jdoe@example.com", "project_name": "Pharos Demo", ... } ] } ``` Iterate `next` until `null`. Collect all `uuid` values. Returns empty list when no pending invitations exist. #### Accept Invitation ``` POST {WALDUR_API_URL}/api/user-invitations/{uuid}/accept/ Authorization: Token {WALDUR_STAFF_TOKEN} Content-Type: application/json ``` Expected: `200 OK` if server-side auto-accept is supported for the staff token flow. This endpoint must be validated because Waldur documents it as accepting the invitation for the currently authenticated token user. On any non-2xx: throw and write failure record for this `uuid`. ### 7. Error Handling and Failure Records Waldur sync failures are never re-raised to `signIn`. They are recorded in Valkey and emitted as structured log lines for the K8s log aggregator. **Valkey key:** `waldur:sync:failed:{email}` — overwrites the previous record if the same user fails again. TTL: 30 days. **Failure record shape:** ```json { "email": "jdoe@example.com", "username": "jdoe", "step": "identity-bridge", "invitationUuid": null, "error": "HTTP 503: Service Unavailable", "timestamp": "2026-05-19T12:00:00.000Z" } ``` For `accept-invitation` failures, `invitationUuid` is populated with the affected invitation's UUID. A separate Valkey key `waldur:sync:failed:{email}:{uuid}` is used so multiple invitation failures for the same user are not overwritten. **Per-step behaviour:** | Step | On failure | |---|---| | Identity Bridge | Write failure record | | Fetch invitations | Write failure record | | Accept invitation (non-2xx) | Write failure record per invitation; continue to next | **Structured log format (all errors):** ```json { "level": "error", "service": "waldur-sync", "step": "identity-bridge", "email": "jdoe@example.com", "statusCode": 503, "message": "Waldur Identity Bridge unavailable" } ``` ### 8. Invitation Email Template Customisation Waldur's default invitation email contains a link that points to Waldur's own UI. Users who click it would land on the wrong product. To close this gap, the Waldur invitation email template must be overridden to replace that link with the Themelio invite URL. #### Target link format ``` https:///?waldur_invite=true ``` When the user clicks this link they enter the invite flow described in section 4: if already logged in they are sent directly to `/api/waldur/accept-invitations`; if not, they see the sign-in page and the `callbackUrl` carries them back to invitation acceptance after Keycloak login. #### How to override the template Waldur supports custom email templates via the Django `constance` configuration or by overriding the template file on the server, depending on the deployment method. The invitation email template key is typically `invitation_created` (or equivalent in the target Waldur version — confirm with the Waldur admin). The relevant placeholder that renders the accept link must be replaced with the static Themelio URL above. This is a one-time configuration step performed by the Waldur instance administrator. It is not automated by Themelio. #### New environment variable | Variable | Description | |---|---| | `THEMELIO_DOMAIN` | Already required. Used to construct the invite URL in Waldur template config: `https://${THEMELIO_DOMAIN}/?waldur_invite=true` | No new code changes in Themelio are required for this section — it is purely a Waldur-side configuration task. ### 9. Admin Failures Endpoint New Next.js route: `GET /api/admin/waldur-sync-failures` - Requires an admin session (server-side check — admin role must be defined; details TBD with auth design). - Reads all `waldur:sync:failed:*` keys from Valkey using `SCAN`. - Returns the list of failure records as JSON for manual operator review and remediation. Operators can use this endpoint to identify users who require manual Waldur provisioning when automated sync fails. ## Testing Strategy - **Unit tests** (`services/web/src/lib/waldur.test.ts`): mock `fetch` and Valkey. Cover: successful Identity Bridge with required `source`, invitation listing via `user-invitations`, invitation acceptance, Waldur 503, invitation 404 (skipped), `writeWaldurSyncFailure`. - **Integration smoke test**: use MSW (Mock Service Worker) to intercept Waldur API calls. Trigger a full `signIn` callback execution. Assert: Identity Bridge runs on every login; failure record written to Valkey when Waldur returns 503. - **Invite link tests**: visit `/?waldur_invite=true` while logged out → sign in → browser reaches `/api/waldur/accept-invitations` → pending invitations accepted → redirected to `/dashboard`. Visit `/?waldur_invite=true` while already logged in → skips login, goes straight to `/api/waldur/accept-invitations` → `/dashboard`. Simulate Waldur 503 on the invite route → user still lands on `/dashboard`, failure record written, login not blocked. - **Acceptance criteria:** A user who logs in for the first time appears in Waldur (verified via Waldur admin UI or `GET /api/users/?email=...`). A user who arrives via `/?waldur_invite=true` has their pending invitation accepted (verified via `GET /api/user-invitations/{uuid}/` showing `state: accepted`). ## Deployment & Rollout 1. Add `WALDUR_API_URL`, `WALDUR_STAFF_TOKEN`, and `WALDUR_IDENTITY_SOURCE` to the Kubernetes Secret references for `services/web` in each environment overlay (`dev`, `staging`, `prod`). In Ansible, `WALDUR_STAFF_TOKEN` is generated by `waldur-offering` and stored in `Secret/themelio-waldur-credentials`. 2. Provision a Waldur staff account with identity manager role and the AAI source registered in `managed_isds` on the target Waldur instance (blocks on ADR-0005 Q3). `WALDUR_IDENTITY_SOURCE` and the user's `managed_isds` entry must match. 3. The feature is fully inert when `WALDUR_API_URL` is unset, so deployment is safe before Waldur credentials exist. 4. No database migrations. No new Kubernetes resources beyond the updated Secret. ## Open Questions 1. **Q3 (from ADR-0005): Waldur identity manager setup** — the Waldur staff account must be provisioned with the identity manager role and the AAI source in `managed_isds` before any Identity Bridge calls will succeed. Blocks production deployment. 2. ~~**Q9 (from ADR-0005): Invitation email customization** — Waldur's default invitation email links to Waldur's own UI. Until this is resolved, users who receive an invitation email may land on the wrong page before logging into Themelio.~~ **Resolved:** The Waldur invitation email template will be overridden by the Waldur instance administrator to replace the accept link with `https:///?waldur_invite=true`. See section 8. 3. **Q10 (from ADR-0005): ToC acceptance** — where Terms & Conditions acceptance is recorded relative to this callback is not yet designed. If ToC must happen before Waldur sync, the guard logic in the `signIn` callback needs a corresponding check. ## References - [Waldur Identity Bridge API](https://docs.waldur.com/developer-guide/identities/) - [Waldur Invitations API](https://docs.waldur.com/developer-guide/invitations/) - [NextAuth v5 — signIn callback](https://authjs.dev/reference/nextjs#signin) - `services/web/src/auth.ts` — existing signIn callback with GitLab provisioning pattern - `services/web/src/auth-helpers.ts` — `ensureInitializedUser`, `createImpersonationToken`