For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt, and this page is available as Markdown at /dd/0005-aai-waldur-user-sync.md.

Themelio compatibility reference. This page describes the retained Themelio implementation or its historical design. Check the parallel-operation guide before applying it to the current platform.

DD-0005: AAI → Waldur User Sync — Implementation

Related ADR: ADR-0005: AAI → Waldur User Synchronization

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:

VariableDescription
WALDUR_API_URLWaldur API base URL, e.g. https://dev.waldur.pharos.grnet.gr
WALDUR_STAFF_TOKENAPI 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_SOURCEWaldur 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):

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).

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://<themelio-domain>/?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 claimIdentity Bridge fieldNotes
preferred_usernameusernameFalls back to sub if absent
WALDUR_IDENTITY_SOURCEsourceRequired; expected Pharos value: isd:pharos
emailemailRequired
given_namefirst_name
family_namelast_name
schac_home_organizationorganizationOptional; 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=<username>&field=uuid&field=username
Authorization: Token <admin-token>

Use the returned uuid to patch the user:

PATCH {WALDUR_API_URL}/api/users/{uuid}/
Authorization: Token <admin-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:

{
  "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:

{
  "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:

StepOn failure
Identity BridgeWrite failure record
Fetch invitationsWrite failure record
Accept invitation (non-2xx)Write failure record per invitation; continue to next

Structured log format (all errors):

{
  "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.

https://<themelio-domain>/?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

VariableDescription
THEMELIO_DOMAINAlready 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://<themelio-domain>/?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

Page source

Source snapshot · Revision f928834c8.