Microsoft Sentinel
Developer and API

Develop Custom Graph Solutions for Microsoft Sentinel

In brief

The new guide explains how to build, test, materialize, query, package, and publish notebook-based custom graph solutions using Sentinel data lake tables and Graph Query Language. It also documents prerequisites, permissions, tools, and the preview status.

What Defender admins need to know

Administrators pursuing this capability should plan for data lake onboarding, the specified RBAC roles and tools, and a Microsoft Partner Center publisher account. No immediate action is required.

Summaries are generated from the documentation change itself.

Documentation change

The comparison below shows only the changed extract. Use the full-page view for complete context.

new file mode 100644

title: Develop Custom Graph Solutions for Microsoft Sentinel description: Build, test, materialize, package, and publish a Microsoft Sentinel custom graph solution to Microsoft Security Store. Get started today. author: EdB-MSFT ms.author: edbaynash ms.reviewer: smarapareddy ms.date: 06/22/2026 ms.topic: how-to ms.service: microsoft-sentinel ms.subservice: sentinel-platform ai-usage: ai-assisted ms.custom: msecd-doc-authoring-1012

#customer intent: As an ISV partner, I want to build and publish a custom graph platform solution so that customers can query and use my graph in Microsoft Security Store.

Develop custom graph platform solutions

A Microsoft Sentinel custom graph solution is a notebook-based solution that defines nodes and edges from Microsoft Sentinel data lake tables, builds a graph, and lets users query that graph with Graph Query Language (GQL).

Use this guide to build, test, materialize, package, and publish a custom graph solution as a SaaS offer in Microsoft Partner Center so customers can discover it in Microsoft Security Store.

Prerequisites

You must be onboarded to the data lake to create Custom Graph solutions. For more information, see How to onboard to the Microsoft Sentinel data lake.

Permissions

The following table lists the required permissions for each operation or scope in this guide.

OperationRequired role
Onboarding to the Sentinel data lakeMicrosoft Entra ID - Security Administrator or Global Administrator
Onboard Sentinel workspace to Defender portalSubscription Owner, or User Access Administrator at subscription scope and Microsoft Sentinel Contributor at subscription or resource group scope
Onboard Sentinel workspace to data lakeSubscription Owner or Microsoft Sentinel Contributor at subscription or resource group scope
Model and build a notebook graphCustom Microsoft Defender XDR unified RBAC role with data (manage) permission over the Microsoft Sentinel data collection
Persist (materialize) a graphSecurity Operator, Security Administrator, or Global Administrator
Query a persisted graphCustom Microsoft Defender XDR unified RBAC role with security data basics (read) over the Microsoft Sentinel data collection
Publishing Custom Graph solutionMarketplace Publisher account on Microsoft Partner Center

Tools

The following tools are required to build and publish a custom graph solution:

Build workflow

The following workflow outlines the steps to build and publish a custom graph solution.

  1. Ingest sample data to the tables that your graph models.
  2. Validate connectivity with a simple notebook.
  3. Author the custom graph.
  4. Test the graph notebook.
  5. Schedule a graph job to materialize the graph.
  6. Query and visualize the graph from Graph explorer.
  7. Package and publish the custom graph solution.

Ingest sample data

This task ingests Microsoft Entra ID asset tables used by the sample graph in this guide: EntraUsers, EntraGroups, EntraServicePrincipals, and EntraMembers.

  1. In the Defender portal, go to System > Settings > Microsoft Sentinel > Data connectors.
  2. Enable the Microsoft Entra ID connector so Entra asset tables are ingested into the data lake. For more information, see Asset data in Microsoft Sentinel data lake.
  3. Confirm these tables appear in the data lake explorer:
    • EntraUsers
    • EntraGroups
    • EntraServicePrincipals
    • EntraMembers
  4. These tables land in the System tables workspace.
  5. Optional: ingest other connectors and replace table names in later steps if your scenario uses different data.

Validate connectivity with a simple notebook

Create and run a minimal notebook to verify data lake connectivity before graph authoring.

  1. Open Visual Studio Code and sign in to the Microsoft Sentinel extension with the same account that has access to the data lake.

  2. In Visual Studio Code, under Sentinel Extension–Graphs select Create new notebook. :::image type="content" source="media/develop-custom-graph-platform-solutions/create-new-notebook.png" lightbox="media/develop-custom-graph-platform-solutions/create-new-notebook.png" alt-text="A screenshot showing the create new notebook option in VS Code.":::

  3. Save the notebook, for example hello-world-show-tables.ipynb.

  4. Select Select kernel, select Microsoft Sentinel and select an available spark pool. :::image type="content" source="media/develop-custom-graph-platform-solutions/select-kernel.png" lightbox="media/develop-custom-graph-platform-solutions/select-kernel.png" alt-text="A screenshot showing the select kernel option in VS Code.":::

  5. Add the following cells in order.

Cell 1 - Markdown:

# Hello World: Show Tables
A minimal connectivity test for the Microsoft Sentinel data lake.
If every cell below runs without error, your environment is ready
to author custom graphs.
Steps:
1. Connect to the data lake with the Microsoft Sentinel provider
2. Point at your workspace
3. Read a table and show a few rows

Cell 2 - Code (connect and set your workspace):

from sentinel_lake.providers import MicrosoftSentinelProvider
# The provider gives the notebook access to data lake tables.
lake_provider = MicrosoftSentinelProvider(spark=spark)
# >>> ADD YOUR WORKSPACE HERE <<<
# Replace with the Log Analytics workspace that holds your tables.
# Entra asset tables (EntraUsers, EntraGroups, ...) live in "System tables".
LOG_ANALYTICS_WORKSPACE = "System tables"
print(f"Using workspace: {LOG_ANALYTICS_WORKSPACE}")

Cell 3 - Markdown:

## Read a table and show rows
Reading a known table is the fastest way to confirm the extension
can reach the lake with your permissions. Swap EntraGroups for any
table that exists in your workspace.

Cell 4 - Code (read a table and show rows):

# Read a table from the data lake into a Spark DataFrame.
df_groups = lake_provider.read_table("EntraGroups", LOG_ANALYTICS_WORKSPACE)
# Show a sample. .df exposes the underlying Spark DataFrame.
df_groups.df.select("id", "displayName", "mailEnabled").show(20, truncate=False)

Cell 5 - Code (optional sanity counts):

# Confirm the read returned data and inspect the schema.
print("Row count:", df_groups.df.count())
df_groups.df.printSchema()
  1. Run all cells top to bottom.
  2. Confirm every cell completes without a red traceback, table preview data appears, and row count is non-zero.

Author the custom graph

Author in one of these ways:

  • AI-assisted graph authoring.
  • Author by hand.

AI-assisted graph authoring

To author a graph with AI assistance, follow these steps:

  1. In Visual Studio Code, open GitHub Copilot Chat.

  2. Start your prompt with the graph-authoring helper @sentinel /graph-authoring and describe your graph in plain language:

    @sentinel /graph-authoring Create a graph that maps Entra groups to
    their member users, groups, and service principals using EntraGroups,
    EntraUsers, EntraServicePrincipals, and EntraMembers from the
    "System tables" workspace.
    

    :::image type="content" source="media/develop-custom-graph-platform-solutions/ai-assisted-graph.png" lightbox="media/develop-custom-graph-platform-solutions/ai-assisted-graph.png" alt-text="A screenshot showing AI-assisted graph authoring in VS Code.":::

    Copilot generates the following cells for the graph authoring lifecycle:

    Lifecycle stageGenerated output
    Environment setupVerifies required packages and connection information
    Data loadingReads named tables from Sentinel data lake
    Data transformationPrepares node and edge data
    Graph schemaDefines nodes and edges
    Schema validationValidates the graph definition
    Graph buildMaterializes the graph for the session
    Graph queryRuns a sample GQL query and visualizes the result
  3. Continue refining the notebook with prompts like these:

    GoalPrompt
    Add a relationship@sentinel Add an edge from User to IPAddress
    Filter data@sentinel Filter the data to show only failed sign-ins
    Change time range@sentinel Change the time range to the last 7 days
    Fix build error@sentinel Fix the error in the graph build step
    Understand code@sentinel Explain how edge keys are defined
    Look up an API without editingWhat parameters does build_graph_with_data() accept? #Sentinel

Author by hand

To author a graph manually, follow these steps:

  1. Connect and read asset tables.

    from pyspark.sql import functions as F
    from sentinel_lake.providers import MicrosoftSentinelProvider
    lake_provider = MicrosoftSentinelProvider(spark=spark)
    # Entra asset tables live in the "System tables" workspace.
    # If your data is elsewhere, update this and ensure the tables exist.
    LOG_ANALYTICS_WORKSPACE = "System tables"
    # Use the latest snapshot of EntraUsers as the point-in-time for all tables.
    snapshot_time = (
        lake_provider.read_table("EntraUsers", LOG_ANALYTICS_WORKSPACE)
        .df.agg(F.max("_SnapshotTime").alias("max_snapshot"))
        .collect()[0]["max_snapshot"]
        .strftime("%Y-%m-%dT%H:%M:%SZ")
    )
    print(f"Using snapshot_time: {snapshot_time}")
    snapshot_filter = (F.col("_SnapshotTime") == F.lit(snapshot_time).cast("timestamp"))
    # Edges: group contains user / group / servicePrincipal
    df_members = (
        lake_provider.read_table("EntraMembers", LOG_ANALYTICS_WORKSPACE)
        .filter(
            snapshot_filter
            & (F.col("sourceType") == "group")
            & (F.col("targetType").isin("user", "group", "servicePrincipal"))
        )
    )
    # Nodes
    df_groups = (
        lake_provider.read_table("EntraGroups", LOG_ANALYTICS_WORKSPACE)
        .filter(snapshot_filter)
        .select("id", "displayName", "mailEnabled")
    )
    df_users = (
        lake_provider.read_table("EntraUsers", LOG_ANALYTICS_WORKSPACE)
        .filter(snapshot_filter)
        .select("id", "accountEnabled", "displayName", "department",
                "userPrincipalName", "usageLocation")
    )
    
  2. Prepare node and edge DataFrames.

    # NODES
    user_nodes = df_users.df.select(
        "id", "displayName", "accountEnabled", "department",
        "userPrincipalName", "usageLocation")
    group_nodes = df_groups.df.select("id", "displayName", "mailEnabled")
    # EDGES
    edge_group_contains_user = (
        df_members.df.filter(F.col("targetType") == "user")
        .select(F.col("sourceId").alias("SourceGroupId"),
                F.col("targetId").alias("TargetUserId"))
        .distinct()
        .withColumn("EdgeKey", F.concat_ws("_", F.col("SourceGroupId"), F.col("TargetUserId")))
    )
    edge_group_contains_group = (
        df_members.df.filter(F.col("targetType") == "group")
        .select(F.col("sourceId").alias("SourceGroupId"),
                F.col("targetId").alias("TargetGroupId"))
        .distinct()
        .withColumn("EdgeKey", F.concat_ws("_", F.col("SourceGroupId"), F.col("TargetGroupId")))
    )
    
  3. Define graph schema and bind DataFrames.

    from sentinel_graph import GraphSpecBuilder, Graph
    entra_group_graph_spec = (
        GraphSpecBuilder.start()
        # === NODES ===
        .add_node("EntraUser").from_dataframe(user_nodes)
        .with_columns("id", "displayName", "accountEnabled", "department",
                      "userPrincipalName", "usageLocation",
                      key="id", display="displayName")
        .add_node("EntraGroup").from_dataframe(group_nodes)
        .with_columns("id", "displayName", "mailEnabled",
                      key="id", display="displayName")
        # === EDGES ===
        .add_edge("ContainsUser").from_dataframe(edge_group_contains_user)
        .source(id_column="SourceGroupId", node_type="EntraGroup")
        .target(id_column="TargetUserId", node_type="EntraUser")
        .with_columns("SourceGroupId", "TargetUserId", "EdgeKey",
                      key="EdgeKey", display="EdgeKey")
        .add_edge("ContainsGroup").from_dataframe(edge_group_contains_group)
        .source(id_column="SourceGroupId", node_type="EntraGroup")
        .target(id_column="TargetGroupId", node_type="EntraGroup")
        .with_columns("SourceGroupId", "TargetGroupId", "EdgeKey",
                      key="EdgeKey", display="EdgeKey")
    ).done()
    # Validate the schema before building.
    entra_group_graph_spec.show_schema()
    
  4. Build the graph.

    # Build = prepare data + publish for the session.
    entra_group_graph = Graph.build(entra_group_graph_spec)
    print(entra_group_graph.build_status.status)   # "published" or "prepared"
    
  1. Run a GQL query.

    # Find nested group relationships up to 8 levels deep.
    # Update the Entra Group name that you want to traverse from
    
    query_nested_groups = """
    MATCH p=(g1:EntraGroup)-[cg]->{1,8}(g2)
    WHERE g1.displayName = 'tmplevel3'
    RETURN *
    """
    entra_group_graph.query(query_nested_groups).show()
    

Test the graph notebook

Test the notebook to ensure it produces the expected graph output:

  1. Restart the kernel and run all cells top to bottom. :::image type="content" source="media/develop-custom-graph-platform-solutions/restart-kernel.png" lightbox="media/develop-custom-graph-platform-solutions/restart-kernel.png" alt-text="A screenshot showing the restart kernel button.":::

  2. Confirm each cell completes successfully.

  3. If transform cells run longer than 5 minutes on a small pool, tighten snapshot filters and column projection before scaling up.

  4. Confirm show_schema() returns expected nodes and edges.

  5. Run a sample GQL query and verify visualization and tabular output.

  6. Validate edge cases:

    • Empty input.
    • Schema drift.
    • Missing table access.

Schedule a graph job to materialize the graph

Graphs created in an interactive session are ephemeral. Schedule a graph job to persist and refresh the graph. For more information, see Schedule and manage Microsoft Sentinel graph jobs.

  1. In your graph notebook, select Create Scheduled Job > Create a graph job. :::image type="content" source="media/develop-custom-graph-platform-solutions/create-graph-job.png" lightbox="media/develop-custom-graph-platform-solutions/create-graph-job.png" alt-text="A screenshot showing the create graph job button.":::

  2. Enter graph Name and Description, and verify the notebook path.

  3. Select a schedule:

    • On demand: builds once, with a default 30-day retention.
    • Scheduled: select repeat cadence and start/end times.
  4. Select Submit.

  5. Open the graph in the Sentinel extension and monitor Job Details.

  6. Use Run Now to run outside schedule as needed.

  7. Confirm status progression from Queued to In Progress to Ready.

Query and visualize from Graph explorer

After the graph job status is Ready, query and visualize in the Defender portal, notebooks, and REST APIs.

  1. In the Defender portal under Microsoft Sentinel, open graph experience and select your materialized graph.
  2. Run GQL queries, inspect schema, switch visual and tabular outputs, and traverse next hop.
  3. Use this GQL quick reference:
Pattern or clause Example
Node with label (g:EntraGroup)
Typed, directed edge -[c:ContainsUser]->
Variable-length path (1 to 8 hops) (a)-[e]->{1,8}(b)
Filter WHERE g.displayName = 'Finance Admins'
Project, sort, limit RETURN g.displayName ORDER BY g.displayName LIMIT 10
Label expressions (:EntraUser | EntraServicePrincipal)
  1. Query from a notebook.

    from sentinel_graph import Graph
    # Attach to an already-materialized graph by name.
    graph = Graph.get("entra_group_membership")
    graph.query("MATCH (g:EntraGroup)-[c:ContainsUser]->(u:EntraUser) RETURN g, c, u").show()
    # Purpose-built security algorithms are available too:
    graph.blast_radius(source_property_value="Finance Admins", min_hop_count=1).show()
    
  2. Query by using Graph REST APIs.

    GET https://api.securityplatform.microsoft.com/graphs/graph-instances?graphTypes=Custom
    Authorization: Bearer <access_token>
    
    POST https://api.securityplatform.microsoft.com/graphs/graph-instances/{graphName}/query
    Authorization: Bearer <access_token>
    Content-Type: application/json
    {
      "query": "MATCH (g:EntraGroup)-[c:ContainsUser]->(u) RETURN g, c, u LIMIT 100",
      "responseFormats": ["Table", "Graph"],
      "queryLanguage": "GQL"
    }
    

responseFormats controls output shape. "Table" returns rows, "Graph" returns nodes and edges, and ["Table", "Graph"] returns both.

For more information, see Graph REST APIs for custom graphs.

Package and publish your graph solution

Once your graph is tested and materialized, package it for deployment to customers. For detailed packaging instructions, see Package and publish Microsoft Sentinel graph and notebook solutions.

Troubleshooting

Use the following tables to diagnose common issues by symptom.

Graph authoring

SymptomLikely cause and fix
MicrosoftSentinelProvider or sentinel_graph not foundSign in again to the extension and confirm a Microsoft Sentinel kernel is selected.
Cell hangs at "Starting Spark session"First startup can take 3 to 5 minutes. If it exceeds 6 minutes, check pool capacity and retry.
show_schema() is empty or Graph.build() failsConfirm key and display columns exist and are non-null, and source and target node_type values match node aliases.
GQL query returns no rowsConfirm filter values exist in the selected snapshot and verify snapshot filtering.
publish() fails with permission deniedConfirm you have Security Operator, Security Administrator, or Global Administrator role.

AI-assisted authoring

SymptomLikely cause and fix
@sentinel /graph-authoring does nothingConfirm GitHub Copilot and Jupyter extensions are installed and active, and you have a Copilot Business or Enterprise plan.
Generated code references a missing tableConfirm connector setup and verify the table appears in the data lake explorer.

Job scheduling

SymptomLikely cause and fix
Local notebook edits aren't reflected in the jobDownload the job notebook from Graphs panel, edit it, then use Edit job > Submit.
Materialized graph disappearedOn-demand graphs have 30-day retention. Schedule a recurring job or run a new build.

Related content