Skip to content

#outline: deep

Bridge Tenant

Overview

Bridge tenants are a new tenant type in Comby v3 that enable B2B cross-tenant collaboration without weakening tenant isolation as a security boundary. Two or more regular tenants can become stakeholders of a shared bridge tenant, where their identities can collaborate on shared data.

Concept

Problem without Bridge Tenants:

In v2, sharing data across tenants required either weakening permissions across the tenant boundary or duplicating data manually. There was no first-class concept for B2B partnerships.

Solution with Bridge Tenants:

A bridge tenant is a regular Comby tenant with type=bridge, in which other tenants are explicitly registered as stakeholders. Stakeholder tenants can invite their identities to the bridge to participate in shared work.

Tenant A (regular)              Tenant B (regular)
    │                                  │
    └──── stakeholder ──────────┐  ┌── stakeholder ────┘
                                ▼  ▼
                         Tenant C (bridge)

                          ├── Bridge identities (Alice@A, Bob@B, …)
                          ├── Bridge workspaces (optional)
                          └── Bridge aggregates (imported or native)

Key Characteristics:

  • Tenant Isolation Preserved: Bridge is just another tenant with tenant_uuid = <bridge>. RLS policies remain tenant_uuid = current_setting('app.tenant_uuid').
  • Hybrid Stakeholder Model: A tenant becomes a stakeholder at the org level, then individual identities still need to be invited separately.
  • Standalone Permissions: Bridge has its own permission model. Heimat-tenant permissions do not flow into the bridge.
  • Lineage: Aggregates copied into the bridge carry an OriginRef recording where they came from.
  • Symmetric Audit: Cross-tenant aggregate movements emit events in both source and destination.

Tenant Type

A new field on the Tenant aggregate distinguishes regular tenants from bridges:

go
type TenantType string

const (
    TenantTypeRegular TenantType = "regular"  // default
    TenantTypeBridge  TenantType = "bridge"
    TenantTypeSystem  TenantType = "system"
)

Create a bridge tenant via the standard TenantCommandCreate with Type=bridge:

go
cmd, _ := comby.NewCommand("Tenant", &command.TenantCommandCreate{
    TenantUuid: "bridge-uuid",
    Name:       "Acme + Globex Partnership",
    Type:       aggregate.TenantTypeBridge,
})

Or via REST:

http
POST /api/tenants
Content-Type: application/json

{
  "tenantUuid": "bridge-uuid",
  "name": "Acme + Globex Partnership",
  "type": "bridge"
}

Stakeholder Lifecycle

The two-step onboarding ensures both sides explicitly agree:

1. Invite (by Bridge admin)

http
POST /api/tenants/{bridgeTenantUuid}/stakeholders
{
  "stakeholderTenantUuid": "tenant-a-uuid"
}

Emits BridgeStakeholderInvitedEvent. Status: invited.

2. Accept (by stakeholder Tenant admin)

http
PATCH /api/tenants/{bridgeTenantUuid}/stakeholders/{stakeholderTenantUuid}

Emits BridgeStakeholderJoinedEvent. Status: joined. Identity invitations can now be sent.

3. Leave (unilateral by stakeholder Tenant admin)

http
DELETE /api/tenants/{bridgeTenantUuid}/stakeholders/{stakeholderTenantUuid}

Emits BridgeStakeholderLeftEvent. Status: left. The stakeholder is no longer in the bridge but historical events remain (archive policy).

Bridge-of-Bridge

Bridge tenants are themselves valid stakeholders of another bridge — verschachtelte Bridges sind erlaubt für komplexe Konsortium-Strukturen. Permission resolution remains tenant-scoped.

Cycles are allowed

A bridge cycle (A is a stakeholder of B, and B is a stakeholder of A) is technically possible and intentionally not blocked. The reason: comby does not resolve stakeholdership transitively. Each tenant — bridge or regular — is its own permission boundary. A stakeholder of A does not implicitly become a stakeholder of B, even if A is a stakeholder of B. Identity invitations are always explicit per bridge.

If a future feature introduces transitive permission resolution, cycle detection becomes a hard requirement at that point. Until then, cycles are simply two bridges that happen to acknowledge each other — no security implication.

Identity Invitations

Once a stakeholder tenant has joined, the bridge admin can invite individual identities (resolved by email → account):

http
POST /api/tenants/{bridgeTenantUuid}/identity-invitations
{
  "invitationUuid": "...",
  "accountUuid": "alice-account-uuid",
  "homeTenantUuid": "tenant-a-uuid",
  "email": "alice@example.com"
}

The invited account accepts:

http
PATCH /api/tenants/{bridgeTenantUuid}/identity-invitations/{invitationUuid}

After acceptance, a regular Identity is created in the bridge linked to the same Account.

Default Permissions: None — the bridge admin must explicitly add the new identity to bridge groups.

Auto-Revoke when Home Stakeholder Leaves

When a stakeholder tenant leaves the bridge (BridgeStakeholderLeftEvent), the tenant reactor automatically revokes all bridge identity invitations whose homeTenantUuid was the leaving stakeholder. This prevents ex-employees of the leaving organisation from retaining bridge access.

The reactor dispatches TenantCommandBridgeRevokeIdentity with Reason="stakeholder-left" for each affected invitation. The status flips to revoked and RevokedReason carries the audit reason.

If the same Account holds bridge identities in OTHER bridges (where their home tenant has not left), those remain unaffected — auto-revoke is scoped strictly to the bridge whose stakeholder left.

Manual Admin Revoke

Bridge admins can revoke an identity invitation explicitly:

http
POST /api/tenants/{bridgeTenantUuid}/identity-invitations/{invitationUuid}/revoke

The corresponding command is TenantCommandBridgeRevokeIdentity with Reason="admin-revoked" (default). Revoke is idempotent — calling it on an already-revoked invitation succeeds without changes.

Aggregate Import (Lineage)

Bridge identities can copy aggregates from their home tenant into the bridge:

http
POST /api/tenants/{sourceTenantUuid}/aggregates/{sourceAggregateUuid}/import-to/{bridgeTenantUuid}
{
  "destinationAggregateUuid": "<freshly-generated-uuid>"
}

Flow:

  1. Validate destination is a bridge tenant
  2. Validate source tenant is a joined stakeholder
  3. Read source aggregate's full event stream
  4. Compute sha256 content hash for OriginRef
  5. Emit AggregateSharedEvent in source tenant (audit)
  6. Clone all source domain events into bridge with new aggregate uuid
  7. Emit AggregateImportedEvent (with full OriginRef) as final marker

OriginRef on the BaseAggregate:

go
type OriginRef struct {
    SourceTenantUuid       string
    SourceAggregateUuid    string
    SourceVersion          int64
    SourceContentHash      string  // sha256 hash of source events' payloads
    ImportedAt             int64
    ImportedByIdentityUuid string
}

The OriginRef is serialized as the _originRef field on the imported BaseAggregate.

Reverse Lineage (Reimport)

Symmetrically, an aggregate can be reimported from the bridge back into a stakeholder tenant:

http
POST /api/tenants/{bridgeTenantUuid}/aggregates/{sourceAggregateUuid}/reimport-to/{targetTenantUuid}

Emits AggregateReimportedEvent with reverse-lineage OriginRef pointing back to the bridge.

Discovery

The user-facing query endpoints to drive a tenant switcher and pending-invitations UI:

http
GET /api/accounts/me/tenants               # all tenants where account has identity
GET /api/accounts/me/bridge-invitations    # pending bridge identity invitations

GET /api/tenants/{tenantUuid}/bridges?status=joined|invited

Code Example

A self-contained, runnable walkthrough lives at code/bridge-app/. It builds two regular tenants, creates a bridge, runs the stakeholder + identity invitation lifecycle end-to-end, and prints the resulting bridge tenant model. All in-memory, no DB needed:

bash
cd src/v3/code/bridge-app
go run .

The example skips authorization (ExecuteSkipAuthorization=true) for brevity. For permission wiring, see Bridge Permission Model below.

Frontend (Admin Dashboard)

The admin dashboard ships with a BridgeIndex.vue page (under /tenants/:tenantUuid/bridges) with three tabs:

  • My Bridges: bridges where the current tenant is a joined stakeholder
  • Pending Invitations: stakeholder invitations awaiting acceptance
  • My Identity Invitations: account-level pending identity invites

Known Implementation Gaps

Two deliberate gaps you should know about — they're not bugs, but the framework hands you the wiring rather than auto-doing it for you.

Bridge Permission Model

The bridge has its own permission model: heimat-tenant permissions do not flow into the bridge. Comby ships default permission functions for the cross-tenant operations that absolutely need them (AcceptStakeholder, LeaveStakeholder, AcceptIdentity) so the lifecycle works out of the box. Beyond that, registering bridge-specific commands and permission entries is a per-deployment concern:

go
import "github.com/gradientzero/comby/v3/domain/runtime"

runtime.RegisterPermission(
    runtime.NewPermissionCmdFunc("Tenant", &command.MyBridgeCommand{},
        "Description", myCustomPermissionFn),
)

See comby/domain/tenant/permissions.bridge.go for the shipped defaults and comby/v3.md §9.2 for the full design rationale.

Bridge Identity → Real Identity Reactor

When BridgeIdentityJoinedEvent fires, the bridge records that the account has accepted — but creating a real Identity aggregate in the bridge tenant (so the account can actually act as Alice@bridge) is left to the application. A reactor that listens to BridgeIdentityJoinedEvent and dispatches IdentityCommandCreate is the standard pattern; comby does not ship one because the choice of profile fields, default permissions, and welcome flows is application-specific.

The frontend admin dashboard already handles the case where a bridge identity record exists with no corresponding Identity aggregate — see comby/v3.md §9.1 for the framework view of this gap.

Design Decisions Reference

For the full list of 20 design decisions (stakeholder model, exit policies, permission model, lineage semantics, etc.), see comby/v3.md Section 8 in the Comby repository.

See Also