Skip to content
🟡Geprüft50%
Vollständigkeit:
50%
Korrektheit:
50%
✓ Geprüft von Peter König am 09. Dezember 2025

Archiviert

Diese Seite gehört zum Archiv des Strategie-Handbuchs und wird nicht mehr als aktuelle Strategie ausgegeben. Aktuelle Plattformstrategie: CIVITAS/CORE und Plattformstrategie.

Konzept: Föderiertes IAM für das p2d2-Netzwerk ​

Status ​

📋 Konzeptionell – Technisch fundiert auf Zitadel v4-Features, Implementierung ausstehend

Zusammenfassung ​

Dieses Dokument beschreibt ein föderiertes Identity and Access Management (IAM) für ein dezentrales p2d2-Netzwerk. Analog zum Fediverse-Modell (Mastodon, etc.) ist jede p2d2-Instanz souverän und selbstständig, kann aber über OIDC-Federation Identitäten anderer Instanzen akzeptieren. Dies ermöglicht:

  • Datenhoheit: Jede Kommune/Organisation kontrolliert ihre User-Daten
  • Cross-Instance-Beiträge: User können instanzübergreifend Geodaten beitragen
  • OSM-Integration: OpenStreetMap bleibt autoritativ für Export-Berechtigungen
  • Skalierbarkeit: Neue Instanzen können ohne zentrale Koordination starten

Motivation ​

Warum födiertes IAM? ​

p2d2 verfolgt das Ziel, Geodaten dezentral zu erfassen und an OpenStreetMap zu übertragen. Im internationalen Kontext ergeben sich mehrere Anforderungen:

  1. Datenschutz und Souveränität: Kommunen und Behörden wollen User-Daten auf eigener Infrastruktur halten (DSGVO, nationale Datenschutzgesetze)
  2. Keine zentrale Abhängigkeit: Kein Single Point of Failure oder Kontrolle durch einzelne Organisation
  3. Zusammenarbeit über Grenzen: User aus Stadt A sollen in Stadt B beitragen können
  4. OSM als finale Datensenke: PostgreSQL/PostGIS ist lokale Staging-Area, keine zentrale DB nötig

Warum Zitadel? ​

  • OIDC-Federation ist native unterstützt
  • Multi-Tenancy für mehrere Organisationen pro Instanz
  • External Identity Providers pro Organisation konfigurierbar
  • Actions/Hooks für Custom Logic (OSM-Integration)
  • Self-Hosting mit PostgreSQL (passt zu PostGIS-Infrastruktur)

Architektur ​

Überblick ​

┌─────────────────────────────────────────────────────────┐
│ p2d2-Köln (data-koeln.openstreetmap.de)                 │
│  ├─ Zitadel-Köln (auth.data-koeln.openstreetmap.de)     │
│  ├─ PostgreSQL/PostGIS (lokale Staging-DB)              │
│  ├─ Astro-Frontend (UI)                                 │
│  └─ Trusted IdPs: [Paris, Warschau, München, ...]       │
└─────────────────────────────────────────────────────────┘
                    ↕ OIDC Federation
┌─────────────────────────────────────────────────────────┐
│ p2d2-Paris (data-paris.openstreetmap.fr)                │
│  ├─ Zitadel-Paris (auth.data-paris.openstreetmap.fr)    │
│  ├─ PostgreSQL/PostGIS (lokale Staging-DB)              │
│  ├─ Astro-Frontend (UI)                                 │
│  └─ Trusted IdPs: [Köln, Warschau, München, ...]        │
└─────────────────────────────────────────────────────────┘
                    ↕ OIDC Federation
┌─────────────────────────────────────────────────────────┐
│ p2d2-Warschau (data-warszawa.openstreetmap.pl)          │
│  ├─ Zitadel-Warschau (auth.data-warszawa...)            │
│  ├─ PostgreSQL/PostGIS (lokale Staging-DB)              │
│  ├─ Astro-Frontend (UI)                                 │
│  └─ Trusted IdPs: [Köln, Paris, München, ...]           │
└─────────────────────────────────────────────────────────┘
                    ↓ Export (alle Instanzen)
              ┌──────────────────────┐
              │   OpenStreetMap      │
              │  (zentrale Geodaten) │
              └──────────────────────┘

Komponenten pro Instanz ​

Zitadel IAM ​

  • Zweck: Identity Management, Authentication, Federation
  • Deployment: Docker-Compose oder LXC
  • Datenbank: PostgreSQL (kann shared mit PostGIS sein, separate DB)
  • Ports: 8080 (hinter Reverse Proxy)

PostgreSQL/PostGIS ​

  • Zweck: Staging für Geodaten, lokale User-Metadaten
  • Schema:
    • p2d2 (Geodaten, Changesets)
    • zitadel (IAM-Daten)

Astro-Frontend ​

  • Zweck: UI für Datenerfassung, Kartendarstellung
  • Auth: OIDC-Client, nutzt Zitadel als IdP
  • APIs: OpenLayers für Karte, Zitadel-APIs für User-Management

Datenflüsse ​

Flow 1: User registriert sich auf Home-Instanz ​

sequenceDiagram
    participant U as User
    participant PK as p2d2-Köln Frontend
    participant ZK as Zitadel-Köln
    participant DB as PostgreSQL

    U->>PK: Registrierung (Email, Passwort)
    PK->>ZK: Create User via API
    ZK->>DB: INSERT INTO zitadel.users
    ZK->>U: Email-Verification
    U->>ZK: Verify Email
    ZK->>PK: Redirect mit ID-Token
    PK->>U: Dashboard anzeigen

Flow 2: User aus Köln arbeitet in Paris (Federation) ​

sequenceDiagram
    participant U as User (Köln-Account)
    participant PP as p2d2-Paris Frontend
    participant ZP as Zitadel-Paris
    participant ZK as Zitadel-Köln
    participant OSM as OpenStreetMap

    U->>PP: Login
    PP->>ZP: Initiate Auth
    ZP->>U: Login-Screen (zeigt externe IdPs)
    U->>ZP: "Login with p2d2-Köln"
    ZP->>ZK: OIDC Authorization Request
    ZK->>U: Login (Köln-Credentials)
    U->>ZK: Username/Password
    ZK->>ZP: ID-Token + Access-Token
    ZP->>ZP: Create Shadow User
    ZP->>PP: Redirect mit Session
    PP->>U: Dashboard (Geodaten Paris)
    
    Note over U,PP: User bearbeitet Daten
    
    U->>PP: Export nach OSM
    PP->>ZP: Check permissions (osm_linked claim)
    ZP->>OSM: OAuth (User's OSM-Account)
    OSM->>PP: Authorized
    PP->>OSM: Changeset Upload

Flow 3: OSM-Account-Linking ​

sequenceDiagram
    participant U as User
    participant PK as p2d2-Köln Frontend
    participant ZK as Zitadel-Köln
    participant OSM as OpenStreetMap OAuth

    U->>PK: "Link OSM Account"
    PK->>OSM: OAuth Authorization Request
    OSM->>U: Login/Consent
    U->>OSM: Grant Access
    OSM->>PK: OAuth Code
    PK->>OSM: Exchange Code for Token
    OSM->>PK: Access Token + User Info
    PK->>ZK: Update User Metadata
    ZK->>ZK: Store osm_user_id, osm_token
    ZK->>PK: Success
    PK->>U: "OSM Account linked"

Technische Implementation ​

1. Zitadel-Deployment pro Instanz ​

Docker-Compose Setup ​

# /opt/p2d2/docker-compose.yml
services:
  zitadel:
    image: 'ghcr.io/zitadel/zitadel:v4.latest'
    command: 'start-from-init --masterkeyFromEnv --tlsMode external'
    environment:
      - 'ZITADEL_DATABASE_POSTGRES_HOST=db'
      - 'ZITADEL_DATABASE_POSTGRES_PORT=5432'
      - 'ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel'
      - 'ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel'
      - 'ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=${DB_PASSWORD}'
      - 'ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable'
      - 'ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=postgres'
      - 'ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=${DB_ADMIN_PASSWORD}'
      - 'ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable'
      - 'ZITADEL_EXTERNALSECURE=true'
      - 'ZITADEL_EXTERNALPORT=443'
      - 'ZITADEL_EXTERNALDOMAIN=auth.data-koeln.openstreetmap.de'
      - 'ZITADEL_MASTERKEY=${MASTERKEY}'
    ports:
      - '8080:8080'
    depends_on:
      db:
        condition: service_healthy
    restart: always

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=${DB_ADMIN_PASSWORD}
      - POSTGRES_DB=zitadel
    volumes:
      - zitadel-db:/var/lib/postgresql/data
      - ./init-postgis.sql:/docker-entrypoint-initdb.d/10-postgis.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: always

volumes:
  zitadel-db:

PostGIS-Integration (Optional) ​

Wenn Zitadel und PostGIS die gleiche PostgreSQL-Instanz nutzen sollen:

-- init-postgis.sql
-- Separate Datenbanken für Zitadel und p2d2
CREATE DATABASE p2d2;
\c p2d2
CREATE EXTENSION postgis;
CREATE EXTENSION postgis_topology;

-- Zitadel-DB wird automatisch vom Container erstellt

2. External Identity Provider konfigurieren ​

In Zitadel-UI (pro Instanz) ​

Settings → Identity Providers → Add Provider → Generic OIDC

Name: p2d2-Paris
Issuer: https://auth.data-paris.openstreetmap.fr
Client ID: p2d2-koeln-federation
Client Secret: [wird von Paris bereitgestellt]
Scopes: openid email profile
Authorization Endpoint: [auto via Discovery]
Token Endpoint: [auto via Discovery]
User Info Endpoint: [auto via Discovery]

Options:
☑ Create users automatically
☐ Link users automatically (Sicherheitsrisiko)
☑ Account creation allowed
☑ Account linking allowed (manual)

Attribute Mapping:
- ID: sub
- Email: email
- Name: name
- Preferred Username: preferred_username

Via Zitadel Management API ​

# Client für Federation in Paris registrieren
curl -X POST https://auth.data-paris.openstreetmap.fr/management/v1/projects/${PROJECT_ID}/apps/oidc \
  -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "p2d2-koeln-federation",
    "redirectUris": [
      "https://auth.data-koeln.openstreetmap.de/oauth/v2/callback"
    ],
    "responseTypes": ["CODE"],
    "grantTypes": ["AUTHORIZATION_CODE", "REFRESH_TOKEN"],
    "appType": "WEB",
    "authMethodType": "POST"
  }'

# Response enthält Client ID und Secret für Köln

3. OIDC Discovery implementieren ​

Jede Zitadel-Instanz exponiert automatisch OIDC Discovery:

# Testen der Discovery
curl https://auth.data-koeln.openstreetmap.de/.well-known/openid-configuration

Beispiel-Response:

{
  "issuer": "https://auth.data-koeln.openstreetmap.de",
  "authorization_endpoint": "https://auth.data-koeln.openstreetmap.de/oauth/v2/authorize",
  "token_endpoint": "https://auth.data-koeln.openstreetmap.de/oauth/v2/token",
  "userinfo_endpoint": "https://auth.data-koeln.openstreetmap.de/oidc/v1/userinfo",
  "jwks_uri": "https://auth.data-koeln.openstreetmap.de/oauth/v2/keys",
  "scopes_supported": ["openid", "profile", "email", "offline_access"],
  "response_types_supported": ["code", "id_token", "token"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"]
}

4. Astro-Frontend Integration ​

OIDC-Client konfigurieren ​

// src/lib/auth/config.ts
import { OIDCClient } from '@zitadel/client';

export const authConfig = {
  issuer: import.meta.env.PUBLIC_ZITADEL_ISSUER,
  clientId: import.meta.env.PUBLIC_ZITADEL_CLIENT_ID,
  redirectUri: `${import.meta.env.PUBLIC_BASE_URL}/auth/callback`,
  scopes: ['openid', 'profile', 'email', 'offline_access'],
  responseType: 'code',
  codeChallengeMethod: 'S256' // PKCE
};

export const oidcClient = new OIDCClient(authConfig);

Login-Flow ​

// src/pages/login.astro
***
import { oidcClient } from '../lib/auth/config';

// Generate PKCE challenge
const codeVerifier = generateRandomString(64);
const codeChallenge = await sha256(codeVerifier);

// Store in session
Astro.cookies.set('code_verifier', codeVerifier, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  maxAge: 600 // 10 minutes
});

// Build authorization URL
const authUrl = oidcClient.createAuthorizationUrl({
  codeChallenge,
  codeChallengeMethod: 'S256',
  state: generateRandomString(32)
});

// Redirect
return Astro.redirect(authUrl);
***

Callback-Handler ​

// src/pages/auth/callback.astro
***
import { oidcClient } from '../../lib/auth/config';

const code = Astro.url.searchParams.get('code');
const state = Astro.url.searchParams.get('state');
const codeVerifier = Astro.cookies.get('code_verifier')?.value;

if (!code || !codeVerifier) {
  return new Response('Invalid request', { status: 400 });
}

// Exchange code for tokens
const tokens = await oidcClient.exchangeCodeForTokens({
  code,
  codeVerifier,
  redirectUri: authConfig.redirectUri
});

// Decode ID token
const idToken = decodeJWT(tokens.idToken);

// Store session
Astro.cookies.set('session', tokens.accessToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: tokens.expiresIn
});

// Redirect to dashboard
return Astro.redirect('/dashboard');
***

5. OSM-Account-Linking ​

OAuth mit OpenStreetMap ​

// src/lib/osm/oauth.ts
export const osmOAuthConfig = {
  authorizationEndpoint: 'https://www.openstreetmap.org/oauth2/authorize',
  tokenEndpoint: 'https://www.openstreetmap.org/oauth2/token',
  clientId: import.meta.env.OSM_CLIENT_ID,
  clientSecret: import.meta.env.OSM_CLIENT_SECRET,
  redirectUri: `${import.meta.env.PUBLIC_BASE_URL}/osm/callback`,
  scopes: ['read_prefs', 'write_api']
};

export async function initiateOSMLink(userId: string): Promise<string> {
  const state = generateRandomString(32);
  
  // Store state with user ID
  await redis.set(`osm_link:${state}`, userId, 'EX', 600);
  
  const authUrl = new URL(osmOAuthConfig.authorizationEndpoint);
  authUrl.searchParams.set('client_id', osmOAuthConfig.clientId);
  authUrl.searchParams.set('redirect_uri', osmOAuthConfig.redirectUri);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', osmOAuthConfig.scopes.join(' '));
  authUrl.searchParams.set('state', state);
  
  return authUrl.toString();
}

OSM-Callback-Handler ​

// src/pages/osm/callback.astro
***
import { osmOAuthConfig } from '../../lib/osm/oauth';
import { updateUserMetadata } from '../../lib/zitadel/api';

const code = Astro.url.searchParams.get('code');
const state = Astro.url.searchParams.get('state');

// Verify state
const userId = await redis.get(`osm_link:${state}`);
if (!userId) {
  return new Response('Invalid state', { status: 400 });
}

// Exchange code for token
const tokenResponse = await fetch(osmOAuthConfig.tokenEndpoint, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: osmOAuthConfig.redirectUri,
    client_id: osmOAuthConfig.clientId,
    client_secret: osmOAuthConfig.clientSecret
  })
});

const tokens = await tokenResponse.json();

// Get OSM user info
const userResponse = await fetch('https://api.openstreetmap.org/api/0.6/user/details.json', {
  headers: { 'Authorization': `Bearer ${tokens.access_token}` }
});

const osmUser = await userResponse.json();

// Store in Zitadel user metadata
await updateUserMetadata(userId, {
  osm_user_id: osmUser.user.id.toString(),
  osm_display_name: osmUser.user.display_name,
  osm_token_encrypted: encrypt(tokens.access_token),
  osm_linked_at: new Date().toISOString()
});

return Astro.redirect('/dashboard?osm_linked=true');
***

6. Zitadel Actions für Token-Enrichment ​

Action: Add OSM Claims to Token ​

Zitadel Console → Actions → Add Action

// Action: EnrichTokenWithOSM
function enrichTokenWithOSM(ctx, api) {
  // Prüfe ob User OSM-Account gelinkt hat
  if (ctx.user.metadata && ctx.user.metadata.osm_user_id) {
    // Füge Claims zum ID-Token hinzu
    api.v1.claims.setClaim('osm_linked', true);
    api.v1.claims.setClaim('osm_user_id', ctx.user.metadata.osm_user_id);
    api.v1.claims.setClaim('osm_display_name', ctx.user.metadata.osm_display_name);
    
    // Optional: Home-Instance für Federation
    api.v1.claims.setClaim('home_instance', ctx.instanceDomain);
  } else {
    api.v1.claims.setClaim('osm_linked', false);
  }
}

Trigger: Pre-Token-Creation

Action: Audit Federation Logins ​

// Action: AuditFederationLogin
function auditFederationLogin(ctx, api) {
  if (ctx.user.externalIdp) {
    // Log föderierten Login
    api.v1.log({
      level: 'info',
      message: 'Federation login',
      userId: ctx.user.id,
      idp: ctx.user.externalIdp.idpId,
      externalUserId: ctx.user.externalIdp.userId,
      timestamp: new Date().toISOString()
    });
    
    // Optional: Metric für Monitoring
    api.v1.metadata.increment('federation_logins_total', {
      idp: ctx.user.externalIdp.idpId
    });
  }
}

Trigger: Post-Authentication

7. Export-Autorisierung ​

Middleware für OSM-Export-Endpunkte ​

// src/middleware/requireOSMLink.ts
import { defineMiddleware } from 'astro:middleware';
import { verifyToken } from '../lib/auth/verify';

export const requireOSMLink = defineMiddleware(async (context, next) => {
  const token = context.cookies.get('session')?.value;
  
  if (!token) {
    return context.redirect('/login');
  }
  
  const payload = await verifyToken(token);
  
  // Prüfe OSM-Link-Claim
  if (!payload.osm_linked) {
    return new Response(JSON.stringify({
      error: 'OSM account not linked',
      message: 'You must link your OpenStreetMap account before exporting data'
    }), {
      status: 403,
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  // Füge OSM-User-ID zum Context hinzu
  context.locals.osmUserId = payload.osm_user_id;
  context.locals.osmDisplayName = payload.osm_display_name;
  
  return next();
});

Export-Endpoint ​

// src/pages/api/export/osm.ts
import type { APIRoute } from 'astro';
import { requireOSMLink } from '../../../middleware/requireOSMLink';
import { createOSMChangeset } from '../../../lib/osm/changeset';

export const POST: APIRoute = async (context) => {
  // Middleware applied via config
  const { changesetData } = await context.request.json();
  
  // Hole verschlüsselten OSM-Token aus User-Metadata
  const userMetadata = await fetchUserMetadata(context.locals.userId);
  const osmToken = decrypt(userMetadata.osm_token_encrypted);
  
  // Erstelle Changeset in OSM
  const changesetId = await createOSMChangeset({
    token: osmToken,
    comment: changesetData.comment,
    source: 'p2d2',
    changes: changesetData.features
  });
  
  return new Response(JSON.stringify({
    success: true,
    changesetId,
    url: `https://www.openstreetmap.org/changeset/${changesetId}`
  }), {
    headers: { 'Content-Type': 'application/json' }
  });
};

// Apply middleware
export const middleware = [requireOSMLink];

Trust-Management ​

Problem: Wie etablieren Instanzen Vertrauen? ​

Wenn eine neue p2d2-Instanz (z.B. p2d2-München) startet, müssen andere Instanzen entscheiden: "Vertrauen wir München?"

Lösung: p2d2-Registry (Optional) ​

Ein einfacher Registry-Service als JSON-Datei in Git:

// https://github.com/p2d2-network/registry/blob/main/instances.json
{
  "instances": [
    {
      "id": "koeln",
      "name": "Stadt Köln",
      "domain": "data-koeln.openstreetmap.de",
      "issuer": "https://auth.data-koeln.openstreetmap.de",
      "admin_contact": "admin@stadt-koeln.de",
      "region": "DE-NW",
      "verified": true,
      "verified_by": "p2d2-core-team",
      "verified_at": "2025-12-01T00:00:00Z",
      "public_key_fingerprint": "SHA256:abc123..."
    },
    {
      "id": "paris",
      "name": "Ville de Paris",
      "domain": "data-paris.openstreetmap.fr",
      "issuer": "https://auth.data-paris.openstreetmap.fr",
      "admin_contact": "admin@paris.fr",
      "region": "FR-IDF",
      "verified": true,
      "verified_by": "p2d2-core-team",
      "verified_at": "2025-12-05T00:00:00Z",
      "public_key_fingerprint": "SHA256:def456..."
    }
  ],
  "schema_version": "1.0",
  "last_updated": "2025-12-09T19:00:00Z"
}

Auto-Discovery via Registry ​

// src/lib/federation/registry.ts
const REGISTRY_URL = 'https://raw.githubusercontent.com/p2d2-network/registry/main/instances.json';

export async function fetchTrustedInstances(): Promise<Instance[]> {
  const response = await fetch(REGISTRY_URL);
  const registry = await response.json();
  
  return registry.instances.filter(i => i.verified);
}

export async function syncFederationConfig() {
  const trustedInstances = await fetchTrustedInstances();
  
  for (const instance of trustedInstances) {
    // Prüfe ob bereits konfiguriert
    const exists = await checkIdPExists(instance.id);
    if (exists) continue;
    
    // Auto-configure external IdP
    await createExternalIdP({
      name: instance.name,
      issuer: instance.issuer,
      // Client-Credentials müssen bilateral ausgetauscht werden
      clientId: await requestFederationClientId(instance.issuer),
      scopes: ['openid', 'email', 'profile']
    });
  }
}

Bilateraler Client-Austausch ​

Für sichere Federation müssen Client-Credentials bilateral ausgetauscht werden:

München kontaktiert Köln:

# München sendet Request an Köln
curl -X POST https://auth.data-koeln.openstreetmap.de/federation/request \
  -H "Content-Type: application/json" \
  -d '{
    "requesting_instance": "data-muenchen.openstreetmap.de",
    "requesting_issuer": "https://auth.data-muenchen.openstreetmap.de",
    "admin_email": "admin@muenchen.de",
    "public_key": "-----BEGIN PUBLIC KEY-----\n...",
    "callback_url": "https://auth.data-muenchen.openstreetmap.de/oauth/v2/callback"
  }'

# Köln Admin approved Request (manuell oder automatisch bei verified instances)
# Response:
{
  "client_id": "muenchen-federation-abc123",
  "client_secret": "secret-xyz789",
  "approved_by": "admin@stadt-koeln.de",
  "approved_at": "2025-12-09T20:00:00Z"
}

DSGVO und Datenschutz ​

Welche Daten werden bei Federation übertragen? ​

Minimal (empfohlen):

  • sub (User-ID der Home-Instanz)
  • email (für Account-Matching)
  • email_verified
  • name (optional)

Nicht übertragen:

  • Passwort-Hashes (verbleiben auf Home-Instanz)
  • Vollständige Profile
  • Geodaten-Beiträge

Shadow-User und Datenminimierung ​

Wenn User aus Köln sich in Paris anmeldet:

-- Paris erstellt nur minimal Shadow-User
INSERT INTO zitadel.users (
  id,
  email,
  email_verified,
  external_idp_id,
  external_user_id,
  home_instance
) VALUES (
  'shadow-123',
  'user@example.com',
  true,
  'p2d2-koeln',
  'koeln-user-456',
  'data-koeln.openstreetmap.de'
);

Keine lokale Speicherung von:

  • Name (optional, kann bei jedem Login via Token abgerufen werden)
  • Profilbild
  • Weitere persönliche Daten

Datenlöschung ​

User kann bei Home-Instanz Account löschen:

  1. Köln löscht Account
  2. Köln sendet Revocation an alle konfigurierten Instanzen (via Webhook/Event)
  3. Paris/Warschau/etc. löschen Shadow-User
// Event-Handler für Account-Deletion
async function handleAccountDeletion(event: UserDeletedEvent) {
  const federatedInstances = await getFederatedInstances();
  
  for (const instance of federatedInstances) {
    await fetch(`${instance.issuer}/federation/user-deleted`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${instance.federationToken}`
      },
      body: JSON.stringify({
        external_user_id: event.userId,
        home_instance: ctx.instanceDomain,
        deleted_at: new Date().toISOString()
      })
    });
  }
}

Sicherheit ​

Token-Validierung ​

Jede Instanz muss Tokens von externen IdPs validieren:

// src/lib/auth/verify.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();

export async function verifyToken(token: string, issuer: string) {
  // Cache JWKS per Issuer
  if (!jwksCache.has(issuer)) {
    const jwksUrl = `${issuer}/oauth/v2/keys`;
    jwksCache.set(issuer, createRemoteJWKSet(new URL(jwksUrl)));
  }
  
  const JWKS = jwksCache.get(issuer)!;
  
  const { payload } = await jwtVerify(token, JWKS, {
    issuer,
    audience: authConfig.clientId
  });
  
  return payload;
}

Rate-Limiting für Federation ​

Schutz vor Missbrauch durch fremde Instanzen:

// src/middleware/federationRateLimit.ts
import { rateLimit } from '@/lib/rateLimit';

export const federationRateLimit = defineMiddleware(async (context, next) => {
  const token = context.request.headers.get('authorization');
  const payload = await verifyToken(token);
  
  // Rate-Limit pro externe Instanz
  const homeInstance = payload.home_instance;
  const limiter = rateLimit({
    windowMs: 60 * 1000, // 1 minute
    max: 100, // 100 requests per minute per instance
    keyGenerator: () => `federation:${homeInstance}`
  });
  
  const allowed = await limiter.check();
  if (!allowed) {
    return new Response('Rate limit exceeded', { status: 429 });
  }
  
  return next();
});

Audit-Logging ​

Alle Federation-Events sollten geloggt werden:

// Event-Types
enum FederationEvent {
  EXTERNAL_LOGIN = 'external_login',
  SHADOW_USER_CREATED = 'shadow_user_created',
  EXPORT_BY_EXTERNAL_USER = 'export_by_external_user',
  TRUST_ESTABLISHED = 'trust_established',
  TRUST_REVOKED = 'trust_revoked'
}

// Log-Schema
interface FederationLog {
  event: FederationEvent;
  timestamp: string;
  local_user_id?: string;
  external_user_id: string;
  home_instance: string;
  ip_address: string;
  user_agent: string;
  metadata: Record<string, any>;
}

// Logger
async function logFederationEvent(log: FederationLog) {
  await db.insert('federation_logs', log);
  
  // Optional: Streame zu zentralem Monitoring
  await monitoring.send({
    type: 'federation_event',
    ...log
  });
}

Monitoring und Observability ​

Metriken ​

// Prometheus-Metriken für Federation
const federationMetrics = {
  logins_total: new Counter({
    name: 'p2d2_federation_logins_total',
    help: 'Total federation logins',
    labelNames: ['home_instance', 'status']
  }),
  
  shadow_users_total: new Gauge({
    name: 'p2d2_federation_shadow_users_total',
    help: 'Current number of shadow users',
    labelNames: ['home_instance']
  }),
  
  token_validation_duration: new Histogram({
    name: 'p2d2_federation_token_validation_duration_seconds',
    help: 'Token validation duration',
    labelNames: ['issuer']
  }),
  
  exports_by_external_users: new Counter({
    name: 'p2d2_exports_by_external_users_total',
    help: 'OSM exports by federated users',
    labelNames: ['home_instance']
  })
};

Health-Check für Federation ​

// src/pages/api/health/federation.ts
export const GET: APIRoute = async () => {
  const trustedInstances = await fetchTrustedInstances();
  const health = [];
  
  for (const instance of trustedInstances) {
    try {
      // Teste OIDC Discovery
      const discovery = await fetch(
        `${instance.issuer}/.well-known/openid-configuration`,
        { timeout: 5000 }
      );
      
      health.push({
        instance: instance.id,
        status: discovery.ok ? 'healthy' : 'unhealthy',
        latency_ms: discovery.headers.get('x-response-time')
      });
    } catch (error) {
      health.push({
        instance: instance.id,
        status: 'unreachable',
        error: error.message
      });
    }
  }
  
  return new Response(JSON.stringify({ instances: health }), {
    headers: { 'Content-Type': 'application/json' }
  });
};

Implementierungs-Roadmap ​

Phase 1: Single-Instance (Aktuell/Q4 2025) ​

Ziel: Basis-IAM für erste p2d2-Instanz

  • [x] Zitadel-Deployment in Proxmox LXC
  • [ ] PostgreSQL-Integration (shared mit PostGIS)
  • [ ] Astro-Frontend OIDC-Integration
  • [ ] OSM-OAuth-Linking
  • [ ] Export-Autorisierung basierend auf OSM-Link

Deliverables:

  • ops.data-dna.eu mit funktionierendem IAM
  • User können sich registrieren, OSM-Account linken, exportieren

Phase 2: Federation-Prototyp (Q1 2026) ​

Ziel: Proof-of-Concept für Federation zwischen zwei Instanzen

  • [ ] Zweite Test-Instanz deployen (dev.data-dna.eu)
  • [ ] Bilaterales Trust-Setup (ops ↔ dev)
  • [ ] OIDC-Federation konfigurieren
  • [ ] Shadow-User-Handling
  • [ ] Cross-Instance-Login testen

Deliverables:

  • User mit ops-Account kann auf dev zugreifen
  • Dokumentation des Federation-Setups

Phase 3: Multi-Tenancy (Q2 2026) ​

Ziel: Mehrere Organisationen pro Instanz

  • [ ] Zitadel Organizations für Kommunen
  • [ ] Org-spezifische externe IdPs (Keycloak-Integration)
  • [ ] Delegiertes Admin-Management pro Org

Deliverables:

  • ops.data-dna.eu hostet mehrere Kommunen
  • Jede Kommune kann eigene IdPs anbinden

Phase 4: Production-Ready Federation (Q3 2026) ​

Ziel: Skalierbare Federation für beliebig viele Instanzen

  • [ ] p2d2-Registry aufsetzen
  • [ ] Auto-Discovery-Mechanismus
  • [ ] Monitoring und Alerting
  • [ ] Audit-Logging
  • [ ] DSGVO-Compliance-Dokumentation
  • [ ] Runbook für neue Instanzen

Deliverables:

  • Neue Instanzen können ohne manuelle Konfiguration joinen
  • Produktions-Ready für internationale Deployments

Offene Fragen ​

1. Trust-Registry: Governance-Modell ​

Frage: Wer darf neue Instanzen in die Registry aufnehmen?

Optionen:

  • Option A: Core-Team kuratiert (Qualitätskontrolle, aber Bottleneck)
  • Option B: Community-Voting (demokratisch, aber komplex)
  • Option C: Self-Service mit Review (schnell, aber Spam-Risiko)

Empfehlung: Start mit Option A, später Option C mit automatischer Verifikation (DNS-Challenge, Email-Verifikation)

2. Revocation: Wie werden kompromittierte Instanzen ausgeschlossen? ​

Frage: Was passiert, wenn eine Instanz gehackt wird oder böswillig agiert?

Lösung:

  • Revocation-List in Registry
  • Webhook-basierte Notification an alle Instanzen
  • Automatisches Deaktivieren des External IdP
// registry/revocations.json
{
  "revocations": [
    {
      "instance_id": "malicious-instance",
      "revoked_at": "2025-11-15T00:00:00Z",
      "reason": "Security incident",
      "revoked_by": "p2d2-security-team"
    }
  ]
}

3. Konfliktlösung bei Email-Kollisionen ​

Frage: Was passiert, wenn User A in Köln und User B in Paris die gleiche Email haben?

Aktueller Ansatz:

  • Shadow-User nutzt (email, home_instance) als Composite-Key
  • user@example.com@koeln ≠ user@example.com@paris

Alternatives Konzept: Email-Ownership-Verification

  • User muss Email verifizieren
  • Nur eine Instanz kann Email "besitzen"
  • Andere Instanzen müssen alternative Identifier nutzen

4. Performance bei vielen föderierten Instanzen ​

Frage: Skaliert das Modell bei 100+ Instanzen?

Potential Bottlenecks:

  • JWKS-Fetching für jede Instanz (Caching hilft)
  • Discovery-Requests (kann gecached werden, TTL: 24h)
  • Shadow-User-Datenbank wächst linear

Optimierungen:

  • Lazy-Loading: Nur konfigurieren, wenn tatsächlich genutzt
  • JWKS-Cache mit TTL
  • Periodisches Cleanup inaktiver Shadow-User

5. Migration bestehender User ​

Frage: Was passiert bei Migration von Single-Instance zu Federation?

Szenario: Köln startet standalone, später kommt Federation hinzu

Lösung:

  • Bestehende User bleiben "native" (home_instance = NULL)
  • Neue föderierte User haben home_instance gesetzt
  • Keine Breaking Changes für existierende User

Referenzen ​

Technische Standards ​

Zitadel-Dokumentation ​

OpenStreetMap ​

Best Practices ​

Änderungshistorie ​

VersionDatumAutorÄnderung
0.12025-12-09Perplexity AIInitial draft nach Spezifikation
0.22025-12-09Peter KönigDurchsicht Draft, QS-Header gesetzt

Nächste Schritte ​

  1. Review dieses Konzepts durch Team und Community
  2. Phase 1 umsetzen: Single-Instance-Deployment
  3. Feedback sammeln von frühen Usern
  4. Phase 2 starten: Federation-Prototyp mit zweiter Instanz
  5. Dokumentation erweitern: Admin-Guide, User-Guide, API-Docs

Fragen, Feedback, Diskussion:

  • GitHub Discussions: github.com/Peter-Koenig/p2d2-docs-hub/discussions
  • Matrix-Channel: #p2d2:matrix.org (geplant)