> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fga.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# B2B via Connections

> Sync external B2B customers into FGA using enterprise connections and SCIM groups, without Auth0 Organizations.

Use this when each external customer maps to its own enterprise connection (a SAML or OIDC connection to their IdP, for example) instead of an Auth0 Organization. A user's tenant membership follows which connection they authenticated through, and which groups their IdP put them in via SCIM. This is an alternative to [B2B via Organizations](https://docs.fga.dev/relationship-sync/use-cases/b2b-organizations.md): no Auth0 Organization is involved anywhere in this model.

A connection is the organization boundary here: a user reaches it either directly, or through a SCIM group that belongs to it.

## Model

FGA model · DSL

## Mapping

```yaml theme={null}
version: "1"

rules:
  - name: "Link user to connection"
    when: >-
      (input.type == "user.created" || input.type == "user.updated") &&
      input.data.context?.connection?.id != nil
    variables:
      user_id: input.data.object.user_id
      connection_id: input.data.context.connection.id
    tuples:
      - user: "user:{{ variables.user_id }}"
        relation: "member"
        object: "connection:{{ variables.connection_id }}"

  - name: "Link group to connection on creation"
    when: input.type == "group.created"
    variables:
      group_id: input.data.object.id
      connection_id: input.data.object.connection_id
    tuples:
      - user: "connection:{{ variables.connection_id }}"
        relation: "connection"
        object: "group:{{ variables.group_id }}"
      - user: "group:{{ variables.group_id }}#member"
        relation: "member"
        object: "connection:{{ variables.connection_id }}"

  - name: "Add user member to group"
    when: >-
      input.type == "group.member.added" &&
      input.data.object.member.member_type == "user"
    variables:
      group_id: input.data.object?.group?.id
      member_id: input.data.object?.member?.id
    tuples:
      - user: "user:{{ variables.member_id }}"
        relation: "member"
        object: "group:{{ variables.group_id }}"

  - name: "Remove user member from group"
    when: >-
      input.type == "group.member.deleted" &&
      input.data.object.member.member_type == "user"
    variables:
      group_id: input.data.object?.group?.id
      member_id: input.data.object?.member?.id
    tuples:
      - action: delete
        user: "user:{{ variables.member_id }}"
        relation: "member"
        object: "group:{{ variables.group_id }}"

  - name: "Revoke all group tuples on deletion"
    when: >-
      input.type == "group.deleted" &&
      input.data.object?.id != nil
    variables:
      group_id: input.data.object.id
    tuple_filters:
      - object: "group:{{ variables.group_id }}"
        action: delete
      - object: "group:"
        user: "group:{{ variables.group_id }}#member"
        action: delete
      - object: "connection:"
        user: "group:{{ variables.group_id }}#member"
        action: delete

tests:
  - name: "user.created with connection context writes direct connection member"
    input:
      type: "user.created"
      data:
        object:
          user_id: "u_alice"
        context:
          connection:
            id: "con_acme_saml"
    expect_tuples:
      - user: "user:u_alice"
        relation: "member"
        object: "connection:con_acme_saml"

  - name: "user.created without connection context writes nothing"
    input:
      type: "user.created"
      data:
        object:
          user_id: "u_alice"
    expect_tuples: []

  - name: "group.created links group to connection and grants member access"
    input:
      type: "group.created"
      data:
        object:
          id: "grp_acme_eng"
          connection_id: "con_acme_saml"
    expect_tuples:
      - user: "connection:con_acme_saml"
        relation: "connection"
        object: "group:grp_acme_eng"
      - user: "group:grp_acme_eng#member"
        relation: "member"
        object: "connection:con_acme_saml"

  - name: "group.member.added adds a user member"
    input:
      type: "group.member.added"
      data:
        object:
          group:
            id: "grp_acme_eng"
          member:
            member_type: "user"
            id: "u_bob"
    expect_tuples:
      - user: "user:u_bob"
        relation: "member"
        object: "group:grp_acme_eng"

  - name: "group.member.deleted removes a user member"
    input:
      type: "group.member.deleted"
      data:
        object:
          group:
            id: "grp_acme_eng"
          member:
            member_type: "user"
            id: "u_bob"
    expect_tuples:
      - user: "user:u_bob"
        relation: "member"
        object: "group:grp_acme_eng"
        action: delete

  - name: "group.deleted revokes all group tuples"
    input:
      type: "group.deleted"
      data:
        object:
          id: "grp_acme_eng"
    expect_tuples: []
    expect_tuple_filters:
      - object: "group:grp_acme_eng"
        action: delete
      - object: "group:"
        user: "group:grp_acme_eng#member"
        action: delete
      - object: "connection:"
        user: "group:grp_acme_eng#member"
        action: delete
```

`connection#member` accepting `group#member` (alongside `user`) is what lets a user reach the connection either directly or through a SCIM group, with the same relation either way. "Link group to connection on creation" writes both the bookkeeping `group --connection--> connection` tuple (which connection owns this group) and the `group#member --member--> connection` tuple that actually grants every group member connection membership. With that in place, a `Check` for `user#member@connection` resolves `true` for a user added directly and for a user added to any group on that connection, and `false` for a user who was never added to either.

> [!NOTE]
>
> Auth0's event schema for `user.created` and `user.updated` only requires `context.tenant`, so `context.connection` is optional and can be absent. Don't rely on it to identify the connection. Read `data.object.identities[].connection` instead, which the schema always includes; it holds the connection's *name*, not its ID, and is paired with that identity's `user_id`.

## Known limitations

* **Connection deletion isn't handled above.** `connection.deleted` exists as an event, but this mapping doesn't clean up a user's direct `connection` membership or a group's `connection` tuple on deletion; add a `tuple_filters` rule modeled on "Revoke all group tuples on deletion" if you need this.
* **Roles.** There's no per-user role assignment tied to a plain connection. If you need roles here, assign them to the SCIM group itself; see [Roles: a group, at the tenant level](https://docs.fga.dev/relationship-sync/use-cases/roles.md#a-group-at-the-tenant-level).
