Skip to content

Multi-tenant Tenant shape + scalar TenantId + (email, tenant) composite identity #41

Description

@wangkanai

Multi-tenant Tenant shape + scalar TenantId + (email, tenant) composite identity

Filing target: github.com/wangkanai/federationIdentity/multi-tenant Feature, Priority P1 (per research
009 §8
P1 row, downstream of the P0 license issue wangkanai/federation#40).

Motivated by: research
agile/research/009-refactoring-shared-federation-domain-from-account/report.md
§8 P1 Tenant row. Drafted from epic
031-oss-contribution-parallel-track/031-03-p1-multi-tenant-tenant-email-tenant.

Sibling P1 issues in this series: 031-02 (scoped
Permission/RolePermission), 031-04 (permission scope primitives


Problem statement

Upstream wangkanai/federation does not currently expose reusable
multi-tenant primitives at the level a consuming platform needs to
build a hosted multi-tenant OIDC identity provider on top of it.
Specifically, three patterns that RiverSync treats as load-bearing
at every layer (store, domain, compiler, RP consumer) are not exposed
as a documented upstream contract — every consumer re-implements them
from scratch, with no shared vocabulary to keep the implementations
aligned.

1. The Tenant entity shape (RiverSync Account/src/Domain/Tenancy/Tenant.cs:14-23)

public class Tenant
{
   /// <summary>Stable Guid key. Default until persisted.</summary>
   public Guid Id { get; set; }

   /// <summary>Tenant classification (e.g. internal, customer, partner).</summary>
   public TenantType TenantType { get; set; }

   /// <summary>Display name.</summary>
   public string Name { get; set; } = string.Empty;

   /// <summary>UTC timestamp of creation.</summary>
   public DateTimeOffset CreatedAt { get; set; }

   /// <summary>UTC timestamp of last update.</summary>
   public DateTimeOffset UpdatedAt { get; set; }
}

Three fields form the public contract: Id (Guid), TenantType
(enum), Name (string). The trailing CreatedAt / UpdatedAt are
audit plumbing and not part of the ask.

2. The TenantType enum (RiverSync Account/src/Domain/Enums/TenantType.cs)

public enum TenantType
{
    /// <summary>RiverSync itself — the platform-internal tenant.</summary>
    Internal,

    /// <summary>A customer tenant (paid, federated identity provider for their org users).</summary>
    Customer,

    /// <summary>A partner tenant (read-mostly, scoped to a RiverSync-issued region / segment).</summary>
    Partner,
}

Canonical values, in declaration order: Internal, Customer,
Partner. The enum is the only allowed classification field on the
Tenant entity — the source XML doc explicitly states "do NOT
collapse partner tiers into a free-form string on the Tenant
entity", and "downstream membership tiers and partner-tier
subdivisions will be added as separate enum values if/when needed".
This is the enum-driven classification, not string-driven anti-pattern
that any consumer re-implementing from scratch will get wrong.

A second non-obvious modeling decision worth surfacing in OSS docs:
the internal tenant is identified by absence of a parent
(NULL Organization.TenantId denotes the internal riversync
tenant — there is no IsInternal flag). Consumers will reproduce this
decision one way or another; it is better that the upstream contract
documents the convention rather than leaving it implicit.

3. The scalar TenantId FK convention on the user entity

// Account/src/Domain/Identity/ApplicationUser.cs:28-33
/// <summary>
/// Foreign-key scalar to the tenancy row. The <c>Tenant</c> navigation
/// property and the EF relationship are configured in 013-05; this
/// property is intentionally scalar to keep 013-02 independent of 013-03.
/// </summary>
public Guid TenantId { get; set; }

The user entity carries TenantId as a scalar property — no
navigation property, no compile-time dependency on the Tenant type.
The FK relationship is configured later, at the persistence layer
(Infrastructure/Configurations/ApplicationUserConfiguration.cs:58-61,
OnDelete(DeleteBehavior.Restrict)). The XML doc names the reason:
build parallelism — keeping 013-02 (user entity) independent of
013-03 (tenant aggregate) so the two features can ship concurrently.

This is captured as a load-bearing convention, not a stylistic
choice, by 013-02 spec line 36-37 (AC2): "Given parallel feature
013-03, When ApplicationUser is authored, Then it holds TenantId
as a scalar only (no compile-time dependency on the Tenant type) —
the FK relationship is configured later in 013-05."

4. The composite (Email, TenantId) UNIQUE identity invariant

// Account/src/Infrastructure/Configurations/ApplicationUserConfiguration.cs:75-91
RemoveDefaultIdentityIndex(builder.Metadata, propertyName: "NormalizedEmail");
RemoveDefaultIdentityIndex(builder.Metadata, propertyName: "NormalizedUserName");

// Add the composite UNIQUE(Email, TenantId) — the multi-tenant email
// invariant.
builder.HasIndex(user => new { user.Email, user.TenantId })
       .IsUnique()
       .HasDatabaseName("ix_application_user_email_tenant");

// Add the composite UNIQUE(NormalizedUserName, TenantId) — the
// multi-tenant sign-in identity invariant.
builder.HasIndex(user => new { user.NormalizedUserName, user.TenantId })
       .IsUnique()
       .HasDatabaseName("ix_application_user_normalized_user_name_tenant");

The headline invariant of the entire 013 epic: a single email
address may be owned by multiple tenants, but within each tenant the
(Email, TenantId) pair is unique
. ASP.NET Core Identity's default
single-column NormalizedEmail UNIQUE blocks this; the configuration
strips the default indexes and replaces them with composite ones.
The sign-in path keys on NormalizedUserName, so both composites
(Email + TenantId AND NormalizedUserName + TenantId) are required
— stripping one without adding its composite lets two tenants collide.

The failure mode this guards against is documented in the same
configuration file (lines 24-31, citing report F14): without the
composite UNIQUE(Email, TenantId), two tenants cannot share an
email address — which means an account cannot be a true multi-tenant
store. The runtime consumer is TenantEmailLookup.cs:96-106 (the
multi-tenant email chooser view), which is only meaningful if the
second insert succeeds and a second row exists to render.

5. The tnt claim convention (wire format)

// Account/src/Application/Authorization/CompiledClaimTypes.cs:58-64
public const string Tenant = "tnt";
// Account/src/Application/Authorization/ClaimsCompiler.cs:146-156
var tnt = new Claim(
   CompiledClaimTypes.Tenant,
   resolved.TenantId.ToString());

Every federated token carries exactly one tenant id — the user cannot
be entitled across multiple tenants on the same token. The tnt
claim is the wire-format projection of ApplicationUser.TenantId,
emitted as a Guid "D" format string (lowercase, no braces) so
that relying-party parsers do not need a full Guid deserializer.
The consuming side reads it as a plain string:

// Account/src/Application/Authorization/PartnerTokenContext.cs:108
var tnt = principal.FindFirstValue("tnt");

Two invariants pinned: (a) one tnt claim per token — the compiler
never emits two; (b) stable string format across versions, as part of
the public contract with every downstream RP consumer.


RiverSync usage context

RiverSync is a six-app product platform with a centralized OIDC
identity provider (Account) and five relying-party apps (Portal,
Admin, Partners, Pipeline, Field). The four anchors below
explain why the upstream contract for tenant identity matters to us —
and why we are asking the question rather than quietly forking.

  • Thin Common foundation (epic 029).
    Epic 029 — Common OSS Foundation Layer
    establishes RiverSync.Common.Domain as a thin shell depending on
    Wangkanai.Domain and Wangkanai.System, with client-side
    federation pieces (claims codec, OIDC RP wiring) already extracted
    into Common/src/Claims/ and Common/src/Web/ without issuer-side
    leakage. This is the Option A recommendation from research 009
    §7: thin extraction in Common, not a full internal fork.

  • Thin federation overlay with TenantInfo projection (epic 030).
    Epic 030 — Extract Federation Domain Models To Common,
    specifically
    030-01 — Core Shared Federation Shapes in Common,
    introduces TenantInfo as a thin Common.Domain overlay —
    Guid Id, Name, and string TenantType with canonical
    values Internal | Customer | Partner (the enum names from
    Account/src/Domain/Enums/TenantType.cs, but the Common type is
    string, not a reference to the Account enum). This is the
    projection that lets RP apps and Field decode tenant context from
    a federated token without taking a dependency on Account.Domain.
    The OSS ask in this issue is for the upstream shapes that
    TenantInfo can be implemented against — without those, Common
    has to re-derive everything from scratch.

  • Cross-tenant ViewAsContext precedent (epic 018).
    Epic 018 — Cross-Tenant Access (View-As)
    established the ViewAsContext pattern — anchored at
    Common/src/Web/ViewAsContext.cs
    for safely traversing tenant boundaries in the platform IdP without
    leaking issuer responsibilities. The tnt claim wire format and
    the one-tenant-per-token invariant are what make ViewAsContext's
    explicit dual-principal traversal tractable: a downstream
    ViewAsSession knows it is operating as the delegating tenant's
    principal against a target tenant's resource, and the wire
    format must make that unambiguous.

  • ARCH:VI — Account is the sole writer.
    Per architecture rule
    .claude/rules/architecture/federation-auth.md
    (extract ARCH:VI):

    • Account is the sole platform OIDC/OAuth2 identity provider —
      the only issuer of the federated token every app trusts.
    • The other five product apps (Portal, Admin, Partners, Pipeline,
      Field) participate strictly as OIDC relying parties; none runs
      its own authorization server or credential store.
    • Common provides consumable shapes (claims codec, RP wiring,
      ViewAsContext, TenantInfo); it does NOT host a token issuer.
    • Multi-tenant user rows are persisted exclusively in
      Account.
      No non-Account app may maintain its own
      ApplicationUser-equivalent store, even if the upstream shapes
      it depends on would technically allow it. The OSS shapes in this
      issue are consumed (read-only projection in RP apps, write path
      only in Account), never re-implemented per-app.

WASM/RP consumer path (why the shapes must be stringly-typed)

The Field app is offline-first WASM/PWA. It needs to decode federated
tokens (the tnt claim, eventually the permission scopes per the
sibling 031-04 issue) without a server round-trip and without
loading any EF Core / SQL Server dependencies
. That constrains the
TenantInfo projection to:

  • string TenantType (not an enum type that drags a host-side
    reference — Field cannot take a dependency on Account.Domain.Enums.TenantType).
  • string representation for the tnt claim value (already the
    case — Guid "D" format).
  • Pure, DI-free value types (no ApplicationDbContext, no navigation
    properties, no [ForeignKey] attributes).

This is exactly what 030-01 spec's TenantInfo(string TenantType)
shape already encodes. The upstream ask in this issue is for the
federation package to expose a compatible primitive — so Common's
TenantInfo is a thin projection overlay rather than a local fork.

No timeline promises (explicit)

To be unambiguous about what this issue is not asking:

  • No timeline promise. This issue does not commit RiverSync to
    any particular release date for adopting any upstream shape.
    Internal sequencing is governed separately by our own epic planning
    and may change without notice to this issue. Concretely: no
    timeline or commitment by RiverSync is tied to the resolution of
    this issue.
  • No adoption commitment. RiverSync has not committed to taking
    a hard Wangkanai.Federation dependency even after this issue is
    resolved. The thin Common strategy in 029/030 is OSS-agnostic and
    is being executed on Wangkanai.Domain + Wangkanai.System today;
    any deeper Federation adoption is contingent on legal/architectural
    review on our side (license clarity first — see P0 issue License consistency + explicit SaaS/hosted multi-tenant IdP terms #40).
  • No licensing-philosophy request. This issue is filed under
    the assumption that the P0 license work in
    wangkanai/federation#40 is
    resolved first. Until then, no RiverSync dependency is taken
    against any Wangkanai.Federation release — only the thin shapes
    in Common.Domain are being authored. This issue asks for
    documented upstream contracts, not for a license change of
    philosophy.

Request

We respectfully ask the wangkanai/federation maintainers to expose
the following three coordinated upstream contracts:

  1. A Tenant base type suitable for thin projection overlay in
    consuming platforms. Minimum surface:

    • Id : Guid (stable key, default until persisted).
    • TenantType — either an enum (Internal | Customer | Partner) or a string discriminator the host can bind, with
      the canonical values documented.
    • Name : string (display name).
    • Audit timestamps (CreatedAt, UpdatedAt) optional but
      recommended.
    • Document the "NULL-FK-on-parent = internal tenant" convention
      (i.e. do not require a separate IsInternal flag) so
      consumers don't have to re-derive it.
  2. A documented FK-scalar convention on the user entity
    User.TenantId : Guid as a scalar property on the domain class,
    with the EF Core / persistence-layer relationship configured
    separately (Infrastructure, not Domain), and
    OnDelete(DeleteBehavior.Restrict). RiverSync wants the contract
    documented
    , not necessarily a new API surface — the constraint is
    that consumers can build user entities that stay decoupled from
    the Tenant aggregate (build-parallelism + WASM-safety).

  3. A composite (Email, TenantId) UNIQUE helper (or a documented
    recipe) so consumers do not have to strip-and-replace the default
    ASP.NET Core Identity indexes themselves. The minimum contract:

    • Replace UNIQUE(NormalizedEmail) with UNIQUE(Email, TenantId).
    • Replace UNIQUE(NormalizedUserName) with
      UNIQUE(NormalizedUserName, TenantId).
    • On-delete: Restrict for User.TenantId → Tenant.Id.
    • Optionally: a tnt claim convention — one Guid-as-string per
      token, Guid "D" format (lowercase, no braces), so RP parsers do
      not need a Guid deserializer.

These three contracts together are the load-bearing shape a hosted
multi-tenant OIDC platform needs from upstream to consume the package
as a thin overlay rather than re-implementing these patterns per-app.


Why now

This is a pre-existing gap, but the timing is right for three
converging reasons that make the platform's multi-tenant IdP usage
visible upstream:

  1. The thin Common strategy is already in flight (epics 029 and
    030), with 030-01 defining TenantInfo as the projection
    overlay. The OSS shapes asked for in this issue are what
    TenantInfo will be implemented against. Without them, Common
    forks the patterns locally and the "enhance our packages" goal
    from the user research question remains unmet.
  2. The cross-tenant ViewAsContext precedent (epic 018) makes
    RiverSync's multi-tenant IdP usage of these primitives a real,
    shipped pattern on our side — not a roadmap sketch. That makes
    the contract gap a current-day gating decision for downstream
    consumers building similar platforms.
  3. The P0 license issue (wangkanai/federation#40) is filed and
    being driven in parallel. Until that resolves, this issue's
    shapes can be authored and reviewed in the abstract; once P0
    resolves, the contracts asked for here become the immediate
    next ask for any RiverSync production dependency.

Per research 009 §8, the P1 Tenant row is adjacent to the
scoped Permission/RolePermission P1 (031-02) and the permission
scope codec P1 (031-04). All three depend on the federation package
being legally consumable (P0) and on the multi-tenant primitives
being exposed (this issue).


References


Drafted by RiverSync for upstream filing against wangkanai/federation;
not yet filed. Once filed, the upstream URL will replace this header note
and the tracker row in docs/oss-contributions.md
will be updated to Filed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions