Skip to content

Cross-tenant / view-as + AccessGrant primitives #45

Description

@wangkanai

Cross-tenant / view-as + AccessGrant primitives

Filing target: github.com/wangkanai/federation (or split to
github.com/wangkanai/foundation if upstream prefers
the base entity home), Priority P2 (per research
009 §8
P2 cross-tenant row).

Motivated by: research
agile/research/009-refactoring-shared-federation-domain-from-account/report.md
§8 P2 row (line 116). Drafted from epic
031-oss-contribution-parallel-track/031-05-p2-p3-items-tracker-small-contributions.


Problem statement

wangkanai/federation (and the underlying wangkanai/foundation base) does
not today expose cross-tenant / view-as primitives — neither the
customer-managed AccessGrant row, nor the staff ViewAsSession lifecycle,
nor a consumable dual-principal claim decoder that an RP can mount on
its layout pipeline. Federation's published models are still
NotImplementedException stubs under "Planned Features" (per 009 §5 F6);
Foundation ships Entity / AuditableEntity bases but no delegation-shaped
or audit-boundary-shaped entities.

Downstream OIDC platforms that need a "RiverSync-style" cross-tenant /
view-as pattern (a customer grants a partner scoped cross-tenant access, a
staff user impersonates a customer member under audit, both flow through
the same dual-principal token) end up re-declaring the entire pair of
primitives locally — including the most load-bearing wire-format detail
(RFC 8693 act + a session-id companion claim) and the never-throw decoder
contract that keeps a corrupt token from logging every user out.

This is the P2 sibling of the P0 license issue (F7 / 031-01), the P1
permission model ask (031-02 / #43), the P1 tenant identity ask (031-03 /
#41), and the P1 codec/claim ask (031-04 / #42). All five sit in the same
upstream gap: the base shapes a multi-app platform needs are either
missing or planned, not shipped.

RiverSync's three-half evidence (one matrix, one decoder)

The cross-tenant pattern is three shapes bound together by one decoder:

  1. AccessGrant — the customer-managed partner grant row. A customer's
    scoped, region-limited, revocable grant of cross-tenant access to a
    partner organization. Four coarse scope booleans (ScopeTelemetry,
    ScopeService, ScopeSites, ScopeInvoices) + nullable RegionId
    narrowing. Anchored at
    Account/src/Domain/AccessGrant.cs:35-105.
  2. ViewAsSession — the staff delegation lifecycle row. A staff
    principal (ActorUserId, the act) impersonates a customer member
    (SubjectUserId, the sub) inside a bounded-lifetime session
    (StartedAt / ExpiresAt / nullable EndedAt). Anchored at
    Account/src/Domain/ViewAsSession.cs:34-111.
  3. ViewAsContext — the consumable decoder for the RP layout pipeline.
    A plain POCO with IsViewAs / ActorSubject / SessionId /
    WriteIsViewAs. Reads the RFC 8693 act claim + the 018-03
    3-char vas (view-as session id) claim off ClaimsPrincipal;
    never throws on a corrupt token. Anchored at
    Common/src/Web/ViewAsContext.cs:79-226.

All three belong on the same upstream issue because they share the
dual-principal pattern: AccessGrant is the customer-side write of
"this partner may access these scopes in this region"; ViewAsSession
is the staff-side write of "this actor is impersonating this subject
for this reason"; ViewAsContext is the read-side decoder that any RP
mounts to render a "you are viewing as ..." banner and to attribute
every write to the correct principal.

The IMPLEMENTED precedent (epic 018)

This is NOT a roadmap sketch on the RiverSync side — epic
018 — Cross-Tenant Access (View-As)
is IMPLEMENTED end-to-end:

  • 018-01 (layer-6 schema) — AccessGrant + ViewAsSession entities,
    their IEntityTypeConfiguration mappings, the FK-scalar convention,
    the FED-14 absence-is-contractual test
    (EntityShapeTests.AuditEvent_Has_No_Fed14_FactoryDefault_Entity).
  • 018-02 (layer-6 resolver) — the runtime that consults AccessGrant
    on a partner-side request and the cross-tenant token exchange.
  • 018-03 (view-as token exchange) — the controller that emits the
    act (RFC 8693 §2.2.1) + vas claim pair and the bounded-lifetime
    ExpiresAt cap.
  • 018-04 (access-grant management) — the customer-side surface for
    setting / revoking scopes.
  • 018-05 (view-as banner) — the consuming MainLayout integration
    that reads ViewAsContext (Common) and renders the cross-tenant
    banner.
  • 018-06 (dual-audit writer) — the audit writer that stamps every
    write with both the staff actor (from act) and the customer
    subject (from sub).
  • 018-07 (cross-tenant acceptance gate) — the FE/QA gate that proves
    the whole chain.

The 018 implementation is the consumer evidence. The P2 ask is for the
upstream base shapes so any OIDC platform can ship the same pattern
without re-implementing the dual-principal wire format and the
never-throw decoder contract from scratch.

Why this is a composition blocker, not a feature request

  1. No AccessGrant row upstream. Consumers wanting a customer-managed
    partner grant row cannot bind to a Foundation base that does not exist.
    They re-declare the 4-scope boolean pattern + the nullable RegionId
    narrowing + the lifecycle status enum locally. Every consumer
    invents a different shape; the wire-level interop that 018-02
    depends on (a partner-side resource server reading the token's
    vas claim to look up AccessGrant) is impossible to standardize.
  2. No ViewAsSession lifecycle row upstream. The bounded-lifetime
    delegation lifecycle (StartedAt / ExpiresAt / nullable EndedAt)
    is the property that makes staff impersonation auditable. Without
    upstream committing to the lifecycle shape, consumers reinvent the
    bounds and audit-evidence is split across N implementations.
  3. No consumable decoder (ViewAsContext) upstream. This is the
    single shape every RP needs. The decoder's three properties are
    load-bearing:
    • POCO (no DI, no IServiceProvider, no async dispose) so it
      works in both server-rendered and WASM/PWA hosts.
    • Never-throw invariant — a corrupt token degrades to
      "no session" instead of 500-ing the layout pipeline and logging
      out every user.
    • Both-required (act AND vas) — either claim alone is treated
      as malformed; the actor identity is required to attribute writes
      and the session id is required for the dual-audit writer and
      the scope-narrowed API gate.
      These three properties together are what makes the cross-tenant
      banner safely mountable on every product app's layout.
  4. The dual-audit pattern is locally proven. 018-06 dual-audit
    writer stamps both act (actor) and sub (subject) on every
    AuditEvent the session produces. If upstream's base AccessGrant
    / ViewAsSession shipped without a wire-format contract for the
    dual-audit pair, every consumer would have to invent their own
    binding and the audit-evidence cross-platform consistency 009 §6
    asks for would not exist.

RiverSync usage context

RiverSync is a six-app product platform. Its OIDC identity-provider role
is centralized in a single application (Account); the other five
(Portal, Admin, Partners, Pipeline, Field) participate strictly
as OIDC relying parties. The following four anchors explain why a
customer-managed AccessGrant row, a staff ViewAsSession lifecycle,
and a consumable ViewAsContext decoder matter to us — and why we are
asking for the shapes 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, ViewAsContext) 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 Common overlays on Foundation (epic 030).
    Epic 030 — Extract Federation Domain Models To Common
    is the follow-on that would push consumable model shapes
    (AccessGrantInfo, ViewAsSessionInfo overlays on
    Foundation.Entity) into Common.Domain, leaving Account as the
    persisted IdP matrix and issuer. Neither 029 nor 030 pins
    Wangkanai.Federation today — both are blocked on the license
    question being resolved first (F7, see 031-01) AND on the
    cross-tenant base shapes being available upstream.

  • IMPLEMENTED cross-tenant precedent (epic 018).
    Epic 018 — Cross-Tenant Access (View-As)
    is the IMPLEMENTED RiverSync epic that proves the
    customer-managed grant + staff delegation + dual-audit pattern works
    end-to-end on a six-app product platform. Anchored at the seven
    018-NN features above. The "shape lives in Common, writer lives in
    Account" pattern from ViewAsContext is exactly the pattern we are
    asking upstream to ship — with the writer (Foundation) on the
    upstream side and the consumer (Common.Web) on the downstream side.

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

    • Account is the sole platform OIDC/OAuth2 identity provider — and
      the only writer of the persisted AccessGrant matrix and the
      ViewAsSession lifecycle rows.
    • The other five product apps (Portal, Admin, Partners, Pipeline, Field)
      participate strictly as OIDC relying parties; none runs its own
      authorization server, credential store, cross-tenant grant
      manager, or staff delegation lifecycle.
    • Common provides consumable shapes (ViewAsContext decoder,
      AccessGrantInfo / ViewAsSessionInfo overlays, claims codec, RP
      wiring); it does NOT host a token issuer or persist a grant /
      session matrix. The base shapes in this issue are what Common
      would overlay against.

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 release. 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 (or Wangkanai.Foundation) 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.
  • No licensing-philosophy request. We are not asking upstream to
    change their licensing philosophy — only that the cross-tenant
    base shapes be available for thin-overlay consumption. The license
    of record (Apache-2.0 vs SSPL v1 vs something else) is a project
    governance decision and not ours to make; it is a sibling concern
    tracked in the P0 issue (F7 / 031-01).
  • No scope-on-Permission request. Scope belongs on AccessGrant
    (4 booleans + nullable RegionId), not on Permission itself.
    See 031-02 / Scoped Permission + RolePermission base shapes for Foundation overlays #43 for the permission-matrix shape.

Request

We respectfully ask the wangkanai/federation (and, if appropriate,
wangkanai/foundation) maintainers to consider shipping the following
three base shapes — two POCOs and one consumable decoder — as thin,
composable, schema-only entities plus a no-DI decoder POCO. We have
ordered them so that the maintainer can accept, reject, or split them
as a single matrix decision.

1. AccessGrant — customer-managed partner cross-tenant grant

A schema-only base (or POCO) entity with:

  • Guid Id — stable key.
  • Guid PartnerLinkId — FK scalar to a partner-link row (the
    customer↔partner pairing; the PartnerLink entity is owned by an
    upstream epic and not introduced here). No navigation property
    on this side (FK-scalar convention, configurable in the consumer's
    mapping).
  • Guid TenantId — FK scalar to the customer Tenant.Id that owns
    this grant (the granting customer, not the receiving partner).
  • Guid? RegionId — optional region-narrowing FK scalar to
    Region.Id. null ⇔ no region limit — the grant is org-wide
    across every region the customer tenant owns.
  • bool ScopeTelemetry / bool ScopeService / bool ScopeSites /
    bool ScopeInvoices — coarse scope switches, all default false.
  • A lifecycle status (enum) — Active (set on any scope on),
    Revoked (set on revoke). Default Active on insert.
  • CreatedAt / UpdatedAt — UTC timestamps.

Mirrors RiverSync's
Account/src/Domain/AccessGrant.cs:35-105.
The four coarse scope booleans are the partner's access switches the
customer manages; the nullable RegionId further narrows the grant.

No embedded compiler / no resolver logic. The 4-scope booleans
are the data; the resolver that consults them lives in a separate
package — never on the base POCO. This is the same
RolePermission_Has_No_ClaimsCompiler_Logic guard pattern
(Account/test/Unit/Authorization/RolePermissionTests.cs:39-56)
extended to the cross-tenant row.

FED-14 absence is contractual. Factory-default access is a
resolver property, NOT a row in this table. The negative invariant
is locked by a reflection-based test that scans the assembly for
"FactoryDefault" / "Fed14" type names. We would love to see upstream
ship the same absence, so the guard becomes a project convention
rather than a per-consumer tax.

2. ViewAsSession — staff delegation lifecycle

A schema-only base (or POCO) entity with:

  • Guid Id — stable key.
  • Guid ActorUserId — FK scalar to ApplicationUser.Id of the
    staff principal doing the impersonation (the act per RFC 8693
    §2.2.1). No navigation property.
  • Guid SubjectUserId — FK scalar to ApplicationUser.Id of the
    customer member being impersonated (the sub).
  • Guid TenantId — FK scalar to the customer Tenant.Id the
    session operates in.
  • Guid OrganizationId — FK scalar to the Organization.Id the
    action crosses (the audit-boundary id).
  • DateTimeOffset StartedAt — UTC instant the delegation was
    authorized.
  • DateTimeOffset? EndedAt — UTC instant the session was stopped
    or revoked. null while the session is still open.
  • DateTimeOffset ExpiresAt — UTC instant the session is no longer
    valid (the bounded-lifetime cap).
  • string Reason — required justification captured at session start
    (the controller rejects an empty value at the store boundary).
  • A session lifecycle status (enum) — Active, Ended, Revoked,
    Expired.
  • CreatedAt / UpdatedAt — UTC timestamps.

Mirrors RiverSync's
Account/src/Domain/ViewAsSession.cs:34-111.
The dual-principal pattern (sub = impersonated subject,
act.sub = staff actor) and the bounded-lifetime cap are the
load-bearing properties; without them the cross-tenant session is
not auditable.

FK-scalar convention. All four FKs (ActorUserId,
SubjectUserId, TenantId, OrganizationId) are scalar Guid FKs
with no navigation property on this side. Matches the FR-006
"Use FK scalars" convention used by every other Account.Domain
aggregate.

3. ViewAsContext — consumable decoder (POCO, never-throw, both-required)

A plain POCO decoder (NOT a DI service, NOT a hosted background
service) with exactly four read-only properties:

  • bool IsViewAs — true iff the principal carries BOTH the act
    (RFC 8693 §2.2.1) AND vas (view-as session id) claims AND both
    decode cleanly.
  • string? ActorSubject — the staff user id parsed from the act
    JSON value's sub key (RFC 8693 §2.2.2 minimal form).
    null when no session is active or the act value is malformed.
  • Guid? SessionId — the Guid form of the vas claim value.
    null when no session is active or the value does not parse as a
    Guid. (Guid.Empty would be a valid Guid, so null is the
    unambiguous absence signal.)
  • bool WriteIsViewAs — write-flag mirroring IsViewAs in the
    current implementation; the two are split so a future
    enforcement-tier code path can flip the write-flag off without
    turning the banner off (or vice-versa) once a real resource server
    enforces the gate.

Construction: public ViewAsContext(ClaimsPrincipal principal)
— reads the claims once, never throws. A null principal is
tolerated and yields the no-session state. A malformed act
JSON value, a missing sub key, a malformed vas Guid, or
either-claim-alone yields IsViewAs = false and all other
properties at their null / false default.

Mirrors RiverSync's
Common/src/Web/ViewAsContext.cs:79-226.
The three contract properties that bind:

  1. POCO — no DI, no IServiceProvider, no async dispose.
    Trivially testable; works in both server-rendered and WASM/PWA
    hosts.
  2. Never-throw invariant — the decoder is on the per-request
    MainLayout render path. A throw here would propagate as a
    500 from the layout pipeline and log out every user on a
    corrupt token. All decoding is wrapped in try/catch with the
    malformed-input case degrading to "no session."
  3. Both-required (act AND vas) — either claim alone is treated
    as malformed and yields IsViewAs = false. The actor identity
    is required to attribute writes, and the session id is required
    for the dual-audit writer and the scope-narrowed API gate.

4. Documentation in a matrix contract note (companion to LICENSE-COMPATIBILITY)

Regardless of which package the base shapes live in, ship a short
CROSS-TENANT-MATRIX.md (or equivalent) in the repo that documents:

  • Which entity holds the customer-managed grant
    (AccessGrant + the 4 scope booleans + nullable RegionId).
  • Which entity holds the staff delegation lifecycle
    (ViewAsSession + dual-principal pattern + bounded-lifetime cap).
  • The dual-principal wire contract (RFC 8693 §2.2.1 act + a
    companion session-id claim, both required, with the decoder's
    POCO / never-throw / both-required contract).
  • The FK-scalar + no-compiler-in-domain guard pattern (the
    RolePermission_Has_No_ClaimsCompiler_Logic reflection guard
    extended to AccessGrant / ViewAsSession).

This is the same posture RiverSync needs for ARCH:VI compliance: a
written, citable contract on the upstream side that consumers can
quote in their own compliance documentation.

What we are NOT asking for

  • No change to upstream's licensing philosophy (tracked separately in
    the P0 issue, F7 / 031-01).
  • No specific ORM shape (FK-scalar convention, configurable in the
    consumer's mapping; no embedded navigations).
  • No enforcement of the cross-tenant scope at the data-layer level
    on the upstream side (the RiverSync implementation defers this
    to the out-of-scope data-layer/gateway initiative; we are not
    asking upstream to take that on).
  • No commitment from RiverSync to take a hard dependency on the
    shipped shape once available.

Why now

The cross-tenant / view-as gap has been a long-standing upstream
absence (Federation's permission models are still "Planned Features"
per 009 §5 F6, and the cross-tenant / view-as shape is the
sister-shipped pattern), but the timing is right for three converging
reasons that make the upstream base-shape gap visible from a
multi-app product platform's perspective:

  1. The IMPLEMENTED 018 precedent is concrete, not hypothetical.
    RiverSync's epic 018 shipped end-to-end on 2026-06-XX (status
    IMPLEMENTED per the epic page). The seven 018-NN features prove
    the customer-managed grant + staff delegation + dual-audit +
    consumable decoder pattern works on a six-app product platform
    without leaking issuer responsibilities — exactly the pattern
    we are asking upstream to ship.
  2. The thin Common overlay plan is concrete, not hypothetical.
    Epic 030's AccessGrantInfo / ViewAsSessionInfo overlays on
    Foundation.Entity are the next concrete step in the
    thin-extraction plan from 009 §7 Option A. The overlay work
    surfaces exactly the missing base shapes upstream needs to ship.
  3. The dual-audit pattern is locally proven. 018-06 dual-audit
    writer stamps both act (actor) and sub (subject) on every
    AuditEvent the session produces. The wire-level binding is
    the same PermClaimCodec pattern from 031-04 — upstream codec,
    downstream decoder, identical wire format across server-rendered
    and WASM/PWA hosts.

Without these base shapes upstream, multi-app OIDC platforms either
re-declare a private cross-tenant grant matrix + staff delegation
lifecycle + dual-audit wire format (duplicating what Foundation was
created to share) or invent a ViewAsContext-style decoder locally
with no upstream authority to anchor it. Neither is a good place for
the platform to be.


References


Drafted by RiverSync for upstream filing against wangkanai/federation
(or wangkanai/foundation if upstream prefers the split).
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