{
  "slug": "Complete-Technical-Migration-Strategy--Migrating-Legacy-Django-Auth-to-Keycloak---OpenFGA-via-ETL-bbf22fd59cf2",
  "title": "Complete Technical Migration Strategy: Migrating Legacy Django Auth to Keycloak & OpenFGA via ETL",
  "subtitle": "A production-ready, four-phase ETL strategy for migrating legacy Django authentication to Keycloak and fine-grained authorization to OpenFGA.",
  "excerpt": "A production-ready, four-phase ETL strategy for migrating legacy Django authentication to Keycloak and fine-grained authorization to OpenFGA.",
  "date": "2026-08-07",
  "tags": [
    "Keycloak",
    "OpenFGA",
    "Django",
    "ETL",
    "Security"
  ],
  "readingTime": "5 min",
  "url": "https://blog.stackademic.com/complete-technical-migration-strategy-migrating-legacy-django-auth-to-keycloak-openfga-via-etl-bbf22fd59cf2",
  "hero": "https://miro.medium.com/v2/resize:fit:700/1*FIVixsQIHFKfFTe-5kLrcQ.png",
  "content": [
    {
      "type": "paragraph",
      "html": "Modernizing identity and authorization infrastructure is a critical step for scaling cloud-native applications. Transitioning from a monolith’s database-backed authorization framework — such as Django’s built-in <code>auth</code> and custom permission models—to a decoupled, API-driven architecture requires careful planning."
    },
    {
      "type": "paragraph",
      "html": "By separating <strong>Authentication (AuthN)</strong> into <strong>Keycloak</strong> and <strong>Fine-Grained Authorization (AuthZ)</strong> into <strong>OpenFGA</strong>, organizations can achieve centralized Single Sign-On (SSO) alongside Google Zanzibar-style relationship-based access control (ReBAC)."
    },
    {
      "type": "paragraph",
      "html": "This article outlines a production-grade, 4-phase ETL migration strategy to transition from legacy relational authorization to a dual Keycloak and OpenFGA architecture."
    },
    {
      "type": "image",
      "src": "https://miro.medium.com/v2/resize:fit:700/1*FIVixsQIHFKfFTe-5kLrcQ.png",
      "alt": "Complete Technical Migration Strategy: Migrating Legacy Django Auth to Keycloak & OpenFGA via ETL",
      "caption": ""
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Architecture Overview: The Target State"
    },
    {
      "type": "paragraph",
      "html": "In the legacy architecture, the database performs heavy table joins across <code>users_user</code>, <code>users_user_groups</code>, <code>auth_group</code>, and <code>auth_permission</code> on every request."
    },
    {
      "type": "paragraph",
      "html": "In the target state, concerns are strictly decoupled:"
    },
    {
      "type": "code",
      "lang": "text",
      "code": "DECISION & DATA FLOW ARCHITECTURE\n\n┌──────────────────────────────────────────────────────────────────────┐\n│ 1. LEGACY MONOLITH DATABASE                                          │\n├──────────────────────────────────────────────────────────────────────┤\n│ PostgreSQL / MySQL                                                   │\n│ Tables: users_user, users_user_groups, auth_group, auth_permission   │\n└──────────────────────────────────┬───────────────────────────────────┘\n                                   │ Phase 1: aggregated JSON query\n                                   ▼\n┌──────────────────────────────────────────────────────────────────────┐\n│ 2. ETL SYNC ENGINE / CRON WORKER                                    │\n├──────────────────────────────────────────────────────────────────────┤\n│ • Extract user profiles, groups, and permissions                    │\n│ • Transform into Keycloak JSON and OpenFGA tuples                   │\n└───────────────────┬────────────────────────────────┬─────────────────┘\n                    │ Phase 2: HTTP REST             │ Phase 3: HTTP REST\n                    ▼                                ▼\n┌────────────────────────────────┐  ┌──────────────────────────────────┐\n│ KEYCLOAK IDENTITY PROVIDER     │  │ OPENFGA AUTHORIZATION ENGINE     │\n├────────────────────────────────┤  ├──────────────────────────────────┤\n│ • User credentials             │  │ • Relationship graph (ReBAC)    │\n│ • Profile attributes           │  │ • Fine-grained permissions      │\n│ • Global realm roles           │  │ • Object/resource mapping       │\n└────────────────┬───────────────┘  └─────────────────┬────────────────┘\n                 │ Authenticate and issue JWT         │ Check permission\n                 └──────────────────┬──────────────────┘\n                                    ▼\n┌──────────────────────────────────────────────────────────────────────┐\n│ 3. APPLICATION BACKEND / MICROSERVICES API MIDDLEWARE               │\n└──────────────────────────────────────────────────────────────────────┘",
      "preserveWhitespace": true
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "<strong>Keycloak (Identity &amp; AuthN):</strong> Acts as the Identity Provider (IdP). It manages user credentials, user profile attributes (<code>is_active</code>), and high-level realm roles (<code>Ticketing</code>).",
        "<strong>OpenFGA (Relationship AuthZ):</strong> Acts as the authorization engine. It stores relationship tuples mapping users to roles, permissions, and specific domain resources (<code>user:john -&gt; member -&gt; role:admin</code>)."
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Phase 1: Database Preparation & Dual-Target Aggregation"
    },
    {
      "type": "paragraph",
      "html": "The first phase involves extracting relational auth state into structured JSON representations optimized for both Keycloak’s <strong>Partial Import API</strong> and OpenFGA’s <strong>Tuple Write API</strong>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "The Phase 1 Extraction Query"
    },
    {
      "type": "paragraph",
      "html": "Using PostgreSQL JSON functions (<code>jsonb_build_object</code>, <code>jsonb_agg</code>), this query collapses raw relational joins into one clean, aggregated row per user."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Phase 2: Keycloak Migration (Authentication & Realm Roles)"
    },
    {
      "type": "paragraph",
      "html": "Once extracted, the <code>keycloak_payload</code> is ingested into Keycloak using the <strong>Partial Import REST API</strong>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Keycloak Partial Import Batch Structure"
    },
    {
      "type": "paragraph",
      "html": "The ETL worker batches the user records into chunks (e.g., 100 users per request) and executes a <code>POST</code> request against Keycloak:"
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "curl -X POST \"http://<KEYCLOAK_HOST>/admin/realms/<YOUR_REALM>/partialImport\" \\\n  -H \"Authorization: Bearer <ACCESS_TOKEN>\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"ifResourceExists\": \"OVERWRITE\",\n    \"users\": [\n      {\n        \"username\": \"user@example.com\",\n        \"email\": \"user@example.com\",\n        \"enabled\": true,\n        \"emailVerified\": true,\n        \"attributes\": {\n          \"is_staff\": [\n            \"false\"\n          ]\n        },\n        \"realmRoles\": [\n          \"Generic sdm\",\n          \"TicketingUser\"\n        ]\n      }\n    ]\n  }'"
    },
    {
      "type": "quote",
      "html": "<strong><em>Credential Note:</em></strong> <em>Passwords are not directly exported in plain text from Django. During initial user migration, users should be imported with temporary credentials or forced to reset passwords on their first OIDC login attempt (<code>requiredActions: [&quot;UPDATE_PASSWORD&quot;]</code>).</em>"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Phase 3: OpenFGA Migration (Fine-Grained Authorization)"
    },
    {
      "type": "paragraph",
      "html": "OpenFGA handles relationship graph evaluations. Before ingesting tuples, an <strong>Authorization Model</strong> written in OpenFGA DSL must be deployed to the target store."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "1. The OpenFGA Authorization Model (DSL)"
    },
    {
      "type": "code",
      "lang": "text",
      "code": "model\n  schema 1.1\ntype user\ntype role\n  relations\n    define member: [user]\ntype feature\n  relations\n    define viewer: [user, role#member]\n    define creator: [user, role#member]\n    define can_view_ticket: viewer\n    define can_instantiate: creator"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "2. Ingesting OpenFGA Tuples via API"
    },
    {
      "type": "paragraph",
      "html": "The ETL pipeline extracts the <code>openfga_tuples</code> generated by the Phase 1 SQL query and posts them to OpenFGA’s Write API:"
    },
    {
      "type": "code",
      "lang": "bash",
      "code": "curl -X POST \"http://<OPENFGA_HOST>:8080/stores/<STORE_ID>/write\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"authorization_model_id\": \"<MODEL_ID>\",\n    \"writes\": {\n      \"tuple_keys\": [\n        {\n          \"user\": \"user:user@example.com\",\n          \"relation\": \"member\",\n          \"object\": \"role:generic_sdm\"\n        },\n        {\n          \"user\": \"role:generic_sdm#member\",\n          \"relation\": \"viewer\",\n          \"object\": \"feature:view_ticket\"\n        }\n      ]\n    }\n  }'"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Phase 4: Automated Synchronization & Application Switchover"
    },
    {
      "type": "paragraph",
      "html": "Because neither Keycloak nor OpenFGA natively pulls data on a cron schedule from custom REST APIs, an <strong>External Sync Worker</strong> is deployed to maintain parity during the transition period."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Continuous Sync Pipeline (Python Example)"
    },
    {
      "type": "code",
      "lang": "python",
      "code": "import os\nimport requests\nimport psycopg2\nfrom psycopg2.extras import RealDictCursor\n\ndef run_iam_etl_sync():\n    conn = psycopg2.connect(os.getenv(\"DATABASE_URL\"))\n    with conn.cursor(cursor_factory=RealDictCursor) as cursor:\n        cursor.execute(\"... Phase 1 SQL Query ...\")\n        records = cursor.fetchall()\n    keycloak_users = []\n    openfga_writes = []\n    for row in records:\n        keycloak_users.append(row[\"keycloak_payload\"])\n        openfga_writes.extend(row[\"openfga_tuples\"])\n    kc_token = get_keycloak_admin_token()\n    requests.post(\n        f\"{os.getenv('KEYCLOAK_HOST')}/admin/realms/{os.getenv('REALM')}/partialImport\",\n        json={\"ifResourceExists\": \"OVERWRITE\", \"users\": keycloak_users},\n        headers={\"Authorization\": f\"Bearer {kc_token}\"}\n    )\n    for i in range(0, len(openfga_writes), 100):\n        batch = openfga_writes[i:i + 100]\n        requests.post(\n            f\"{os.getenv('OPENFGA_HOST')}/stores/{os.getenv('STORE_ID')}/write\",\n            json={\"writes\": {\"tuple_keys\": batch}}\n        )\n\nif __name__ == \"__main__\":\n    run_iam_etl_sync()"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Scheduling the Sync"
    },
    {
      "type": "paragraph",
      "html": "Deploy the sync worker using an OS <code>crontab</code>, Airflow DAG, or Kubernetes <code>CronJob</code>:"
    },
    {
      "type": "code",
      "lang": "yaml",
      "code": "apiVersion: batch/v1\nkind: CronJob\nmetadata:\n  name: iam-etl-sync\nspec:\n  schedule: \"0 * * * *\"\n  jobTemplate:\n    spec:\n      template:\n        spec:\n          containers:\n            - name: sync-worker\n              image: mycompany/iam-etl-worker:latest\n          restartPolicy: OnFailure"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Application-Level Authorization Check"
    },
    {
      "type": "paragraph",
      "html": "Once Phase 4 is live, backend services validate identity via Keycloak JWTs and delegate permission checks directly to OpenFGA:"
    },
    {
      "type": "code",
      "lang": "python",
      "code": "# API Endpoint Permission Check\n\ndef check_user_permission(user_email: str, permission: str, resource: str) -> bool:\n    response = requests.post(\n        \"http://openfga:8080/stores/YOUR_STORE_ID/check\",\n        json={\n            \"tuple_key\": {\n                \"user\": f\"user:{user_email}\",\n                \"relation\": permission,\n                \"object\": resource\n            }\n        }\n    )\n    return response.json().get(\"allowed\", False)\n\n# Usage in API middleware:\n# allowed = check_user_permission(\"user@example.com\", \"can_view_ticket\", \"feature:view_ticket\")"
    },
    {
      "type": "image",
      "src": "https://miro.medium.com/v2/resize:fit:700/1*L-Pz5eejz7QoZcI-Is8Wrg.png",
      "alt": "Complete Technical Migration Strategy: Migrating Legacy Django Auth to Keycloak & OpenFGA via ETL",
      "caption": ""
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Migration Checklist Summary"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "[x] <strong>Database Audit:</strong> Verified custom user table (<code>users_user</code>) and role structures (<code>users_user_groups</code>, <code>auth_group</code>).",
        "[x] <strong>Extract (Phase 1):</strong> Executed aggregated JSON SQL query to prepare dual payloads.",
        "[x] <strong>Load Keycloak (Phase 2):</strong> Imported users, profile attributes (<code>is_active</code>), and macro realm roles via Partial Import API.",
        "[x] <strong>Load OpenFGA (Phase 3):</strong> Deployed DSL model and written relationship tuples (<code>member</code>, <code>can_view_ticket</code>).",
        "[x] <strong>Automation (Phase 4):</strong> Scheduled background sync worker (CronJob/Airflow) to keep authorization state synchronized until cutover"
      ]
    }
  ]
}
