#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 remaintenant_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
OriginRefrecording 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:
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:
cmd, _ := comby.NewCommand("Tenant", &command.TenantCommandCreate{
TenantUuid: "bridge-uuid",
Name: "Acme + Globex Partnership",
Type: aggregate.TenantTypeBridge,
})Or via REST:
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)
POST /api/tenants/{bridgeTenantUuid}/stakeholders
{
"stakeholderTenantUuid": "tenant-a-uuid"
}Emits BridgeStakeholderInvitedEvent. Status: invited.
2. Accept (by stakeholder Tenant admin)
PATCH /api/tenants/{bridgeTenantUuid}/stakeholders/{stakeholderTenantUuid}Emits BridgeStakeholderJoinedEvent. Status: joined. Identity invitations can now be sent.
3. Leave (unilateral by stakeholder Tenant admin)
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):
POST /api/tenants/{bridgeTenantUuid}/identity-invitations
{
"invitationUuid": "...",
"accountUuid": "alice-account-uuid",
"homeTenantUuid": "tenant-a-uuid",
"email": "alice@example.com"
}The invited account accepts:
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:
POST /api/tenants/{bridgeTenantUuid}/identity-invitations/{invitationUuid}/revokeThe 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:
POST /api/tenants/{sourceTenantUuid}/aggregates/{sourceAggregateUuid}/import-to/{bridgeTenantUuid}
{
"destinationAggregateUuid": "<freshly-generated-uuid>"
}Flow:
- Validate destination is a bridge tenant
- Validate source tenant is a joined stakeholder
- Read source aggregate's full event stream
- Compute sha256 content hash for
OriginRef - Emit
AggregateSharedEventin source tenant (audit) - Clone all source domain events into bridge with new aggregate uuid
- Emit
AggregateImportedEvent(with fullOriginRef) as final marker
OriginRef on the BaseAggregate:
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:
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:
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|invitedCode 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:
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:
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
- Workspace — fine-grained authorization within a tenant
- Row Level Security — Postgres RLS pattern complementing tenant isolation
- Migration v2 → v3 — upgrade guide