Skip to content

P2: AccessScope / PermScope discriminated + only-narrowing Intersect + AppKey entitlement shapes #44

Description

@wangkanai

P2: AccessScope / PermScope discriminated + only-narrowing Intersect + AppKey entitlement shapes

Filing target: github.com/wangkanai/federation (with fallback to a shared claims package, same target as the P1 codec filing at #42) — Feature request, Priority P2 (per research
009 §8
P2 row, line 115).

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

Status of siblings: P0 (license consistency, 031-01) is Filed upstream per
the tracker. P1 (031-02 scoped Permission / RolePermission,
031-03 multi-tenant Tenant, 031-04 codec + value-type primitives) are all Filed
in this series. This issue is the P2 enhancement half — the resolved
entitlement shape + per-app AppKey matrix beyond the codec/value-type primitives.

Defer note — already filed in P1: the deterministic v1: / v1z: packed perm
codec + the PermScope / PermScopeMap value-type primitives (the per-permission
scope half of the AccessScope model) are out of scope for this P2 ask and
already filed at
#42 — WASM-safe PermScope + AccessScope value types and deterministic v1:/v1z: packed perm codec.
This P2 issue deliberately asks for the consumption-side shape (the resolved
entitlement + per-app AppKey matrix) on top of those primitives, NOT for a
separate scope-discriminated value type.


Problem statement

A consumer of wangkanai/federation who needs the resolved-access
entitlement shape (the per-app AppKey matrix plus the (App, Perm) → Scope
map) currently has to re-implement that shape from scratch. The codec
primitives and the per-permission PermScope value type are covered by the
P1 filing at #42; what is missing is the higher-level consumption shape
that the resolver produces and that the Seq 3 claims compiler consumes:

  1. The discriminated AccessScope value type with an only-narrowing
    Intersect truth table — three cases (OrgWide, Region(Id), Site(Id))
    with the closed-set-by-construction pattern.
  2. The resolved-access record (ResolvedAccess) carrying the four
    components every later pipeline stage reads:
    • EntitledAppKeys (the apps after the layer-1 entitlement gate),
    • AppToRoleId (exactly one role per entitled app, the
      UNIQUE(UserId, AppKey) invariant),
    • Permissions (the resolved (app, perm) → scope map),
    • TenantId (the resolution's tenant).
  3. The Application (AppKey) entity carrying the per-app discriminator
    that scopes a role to one application — the AppKey string that keys
    ApplicationUserRole and makes the unique-role rule
    (RoleName, AppKey) rather than (RoleName, ApplicationId).

These three shapes together are what every federated consumer actually
consumes; without them, every consumer re-derives the truth table
(AccessScope.Intersect), the four-component record layout, and the
AppKey string contract. The P1 codec filing covers the per-permission
half; this P2 filing covers the resolved-entitlement + per-app matrix half.

RiverSync ships a production implementation of all three primitives in
Account/src/Application/Authorization/AccessScope.cs,
Account/src/Application/Authorization/ResolvedAccess.cs, and
Account/src/Domain/Authorization/Application.cs. We are asking upstream to
expose consumption-side shapes (a reusable AccessScope value type, a
ResolvedAccess record, an Application (AppKey) entity) with the
contracts the next section pins, so that:

  1. A consumer integrating with wangkanai/federation can resolve a
    federated principal into a typed (entitled apps, role per app, per-permission scope, tenant) record instead of re-deriving it.
  2. The AccessScope.Intersect truth table — the only-narrowing
    invariant that FED-10 (the one-way denial rule) leans on — becomes
    reusable across consumers instead of every consumer re-deriving the
    truth table from a written spec.
  3. The AppKey string contract (the per-app discriminator that scopes a
    role to one application) is owned upstream so cross-consumer
    entitlement checks can compare (app, perm) keys on equal terms.

Concrete evidence — what RiverSync ships today

Artifact Lines What it pins
Account/src/Application/Authorization/AccessScope.cs 28-30 abstract record + private ctor (closed set by design)
Account/src/Application/Authorization/AccessScope.cs 38, 48, 58 OrgWide / Region(Id) / Site(Id) sealed nested records
Account/src/Application/Authorization/AccessScope.cs 64-81 resolver-only construction (factories)
Account/src/Application/Authorization/AccessScope.cs 83-125 Intersect truth table (only-narrowing, null on incompatible)
Account/src/Application/Authorization/AccessScope.cs 126-149 identity element + same-kind collapse + Region∩Site → null
Account/src/Application/Authorization/AccessScope.cs 109-117 Region ∩ Site purity note (resolver owns the site.RegionId lookup)
Account/src/Application/Authorization/ResolvedAccess.cs 53-57 the four-component record signature: EntitledAppKeys / AppToRoleId / Permissions / TenantId
Account/src/Application/Authorization/ResolvedAccess.cs 65-69 Empty sentinel (typed "no access" without per-call alloc)
Account/src/Application/Authorization/ResolvedAccess.cs 11-32 OIDC-agnostic guarantee (no token-issuance SDK reference)
Account/src/Domain/Authorization/Application.cs 16-22 Application entity: Id (Guid) + AppKey (per-app discriminator string)
Account/src/Domain/Authorization/Application.cs 21-22 "AppKey ... Human-readable, e.g. 'portal'" — the per-app contract
Account/src/Domain/Authorization/Application.cs 9 "(RoleName, AppKey)" unique-rule, NOT "(RoleName, ApplicationId)" — the AppKey-not-Id contract
Account/src/Application/Authorization/CompiledClaimTypes.cs (referenced) downstream consumer of ResolvedAccess; proves the four-component record is the substrate every later stage reads
Account/src/Application/Authorization/AccessResolver.cs (referenced) producer of ResolvedAccess; single per-sign-in instance, hands it to callers
Account/src/Application/Authorization/ClaimsCompiler.cs (referenced) Seq 3 claims compiler; reads ResolvedAccess and projects onto a federated principal
agile/research/009-refactoring-shared-federation-domain-from-account/report.md §8 P2 row line 115 research anchor for the P2 framing
agile/research/009-refactoring-shared-federation-domain-from-account/report.md §7 Option A line 65 the recommended layering (extract reusable value types in Common; keep full persisted entities in Account)
Epic 031-oss-contribution-parallel-track/031-04-p1-scope-primitives-deterministic-codec sister P1 filing at #42 (codec + value-type primitives; deliberately not duplicated here)
Epic 029-common-oss-foundation-layer/029-03-generalized-scope-claim-primitives internal generalization of AccessScope to Common.Domain (Status IMPLEMENTED) — the local mirror of this upstream ask

RiverSync context

This P2 ask is grounded in four local anchors — without them, the
upstream framing reads as a request for new shapes. With them, it
reads as a request to publish shapes RiverSync has already validated
in production.

  • Epic 029-03 — generalized scope/claim primitives — the same
    029 spike is the foundation. The AccessScope value type
    (Account/src/Application/Authorization/AccessScope.cs) was
    generalized into Common.Domain as part of the 029-03 track
    (Status IMPLEMENTED). The upstream ask is for the same shape
    to live in wangkanai/federation (or a sibling package) so the
    Federation consumers get it without the 029-03 spike.
  • Epic 030 — thin shapes — these entitlement types would live in
    Common.Domain (per the 030 thin-extraction plan). The 030 epic is
    the in-tree surface for the shapes this issue asks upstream to
    publish. RiverSync does not duplicate them: 030 is the consumer,
    not the source, of any upstream shape.
  • 031-04 P1 codec (WASM-safe PermScope + AccessScope value types and deterministic v1:/v1z: packed perm codec #42) — natural extension; defer note referenced
    the P1 codec ask at WASM-safe PermScope + AccessScope value types and deterministic v1:/v1z: packed perm codec #42 covers the per-permission codec half
    (PermScope / PermScopeMap + the deterministic v1: / v1z:
    packed perm codec). The 031-04 spec explicitly defers the
    consumption-side shape (the resolved-access record + per-app AppKey
    matrix) to the 031-05 P2 track. This P2 ask is the deferred half.
  • ARCH:VI — AccessScope is the consumption shape; Account is the
    only writer
    — per .claude/rules/architecture/federation-auth.md,
    Account is the sole platform OIDC/OAuth2 identity provider AND the
    sole writer of the persisted role↔permission matrix. AccessScope
    is the consumption shape the resolver produces; it is not an
    issuer shape. The five non-Account product apps (Portal, Admin,
    Partners, Pipeline, Field) participate strictly as OIDC relying
    parties; none of them persists a role↔permission matrix. This P2
    ask is for the consumption shape on top of the codec primitives,
    not for a new IdP library.

No timeline promises (explicit)

  • No commitment to a specific RiverSync release date for adopting any
    upstream release.
  • No commitment that this draft binds RiverSync to take a hard
    Wangkanai.Federation dependency even after resolution.
  • No request that upstream change their licensing philosophy.
  • Internal sequencing of RiverSync's adoption is governed separately
    and may change without notice.

Request

We respectfully ask the wangkanai/federation maintainers to ship the
following three coordinated consumption-side shapes so the resolved-access

  • per-app AppKey matrix is consumable from upstream packages, not just
    from RiverSync's Account.Application:

1. A reusable AccessScope value type with only-narrowing Intersect

  • Shape: abstract record AccessScope with a private constructor
    forcing the closed set; three sealed nested records:
    • OrgWide (no Id, no inline suffix on the wire),
    • Region(Guid Id) (narrowed to a single Region.Id),
    • Site(Guid Id) (narrowed further to a single OrganizationSite.Id).
  • Type system enforces the closed set — analogous to
    AccessScope.cs:28-30 (abstract record + private ctor + sealed nested
    records). The resolver never constructs a custom scope type;
    narrowing is the only operation that produces new values.
  • Factories for resolver-only construction:
    • OrgWideInstance (shared singleton for the common case),
    • OrgWideScoped() / RegionScoped(Guid id) / SiteScoped(Guid id).
  • Intersect(AccessScope, AccessScope) → AccessScope? — the
    only-narrowing truth table:
    • OrgWide ∩ x = x (identity element),
    • Region(r) ∩ Region(r') = Region(r) iff r == r', else null,
    • Site(s) ∩ Site(s') = Site(s) iff s == s', else null,
    • Region ∩ Site (in either order) is the resolver's call — pure
      method returns null; the caller looks up site.RegionId.
  • Pure on the discriminated valuesIntersect MUST NOT take a
    dependency on the entity store; putting the Region ∩ Site branch
    in the resolver (not here) keeps AccessScope free of any reference
    to the 013 store, mirrors AccessScope.cs:109-117.

2. A ResolvedAccess consumption record

  • Shape: public sealed record ResolvedAccess(...) with the
    four-component signature RiverSync uses today
    (ResolvedAccess.cs:53-57):
    • IReadOnlyList<string> EntitledAppKeys — the apps the user is
      entitled to after the layer-1 entitlement gate.
    • IReadOnlyDictionary<string, Guid> AppToRoleId — exactly one role
      per entitled app (the UNIQUE(UserId, AppKey) invariant; no
      role-union). Carries the role FK scalar only.
    • IReadOnlyDictionary<(string AppKey, string PermKey), AccessScope> Permissions
      the resolved (app, perm) → scope map. Denied rows are absent
      (FED-10: they can never reappear).
    • Guid TenantId — the tenant the resolution is scoped to; always
      set; Guid.Empty for the Empty sentinel.
  • Closed by construction — primary constructor is the only public
    surface; the resolver builds one per sign-in and hands it to
    callers; nothing outside the resolver package is expected to mutate
    it.
  • OIDC-agnostic — the record MUST NOT reference any token-issuance
    SDK type. The Seq 3 claims compiler in 015-02 maps ResolvedAccess
    onto a federated principal; that mapping is the seam that joins the
    resolver to the rest of the platform. Mirrors
    ResolvedAccess.cs:35-40.
  • Equality is structuralsealed record makes equality
    value-based on all four components. Empty therefore compares equal
    to any other ResolvedAccess built from the four empty singletons
    (the desired behaviour for error paths and "no access" results).
  • A typed Empty sentinelResolvedAccess.Empty for the
    "no access" case without per-call allocation
    (ResolvedAccess.cs:65-69).

3. An Application (AppKey) entity

  • Shape: public class Application carrying:
    • Guid Id (stable key),
    • string AppKey (per-app discriminator, the human-readable string
      e.g. "portal"),
    • string Name (display name),
    • ApplicationGating ApplicationGating (additional login gate
      enum: None, RequireMfa, RequireApproval),
    • DateTimeOffset CreatedAt / UpdatedAt (audit timestamps).
  • The AppKey-not-Id contract — the unique-role rule is
    (RoleName, AppKey), NOT (RoleName, ApplicationId). Mirrors
    Application.cs:9 ("(RoleName, AppKey) rather than
    (RoleName, ApplicationId)"). This is the most often-missed contract
    on the per-app matrix; making it explicit upstream prevents consumers
    from accidentally keying on the surrogate Id and producing roles
    that don't compose across apps.
  • AppKey is the wire-level cross-consumer contract — when a
    federated token carries an (App, Perm) key, the App half is the
    AppKey string. Consumers comparing entitlement keys compare
    AppKey strings, not Id GUIDs.

Why now

The P1 codec filing at #42 covers the per-permission half (the
PermScope / PermScopeMap value types and the deterministic v1: /
v1z: packed perm codec). The P1 scopes-Permission / RolePermission
filing at #43 covers the role↔permission matrix with
Allowed + ScopeRegionId / ScopeSiteId + one-way narrowing. The P1
multi-tenant Tenant / (email, tenant) filing at #41 covers the
tenant identity invariant.

What is not yet covered is the consumption-side shape: the
resolved-access record the resolver produces, the AppKey matrix that
scopes roles to one application, and the discriminated AccessScope
value type that ties it all together. These are the primitives every
later pipeline stage reads; without them, every consumer re-derives the
truth table, the four-component record layout, and the AppKey string
contract from a written spec.

The P2 framing is the right altitude for this ask: the codec
primitives are P1 because the wire contract stabilizes immediately on
issue, but the consumption-side shapes are P2 because they are
designed-for-reuse rather than wire-critical — a consumer can survive
without them, just with more boilerplate.


Scope — what this issue is and is not

This issue IS asking for

  1. A reusable AccessScope value type with the only-narrowing Intersect
    contract (three cases, abstract record + private ctor + sealed nested
    records, factories, pure Intersect).
  2. A ResolvedAccess consumption record (four components, structural
    equality, Empty sentinel, OIDC-agnostic).
  3. An Application (AppKey) entity carrying the per-app discriminator
    string and the AppKey-not-Id unique-role contract.

This issue is NOT asking for

  1. A second PermScope value type — the per-permission PermScope
  2. The deterministic v1: / v1z: packed perm codec — already in
    WASM-safe PermScope + AccessScope value types and deterministic v1:/v1z: packed perm codec #42. The codec is the per-permission half; AccessScope is the
    resolved-access half. Same target, complementary scope.
  3. A new authorization-server / IdP library — the per-app matrix
    and the AppKey contract are consumption shapes, not issuer
    shapes. The ResolvedAccess record is OIDC-agnostic by design (per
    ResolvedAccess.cs:35-40); the per-app matrix is not an
    OpenIddict / OpenIdConnect artifact.
  4. The persisted role↔permission matrixRolePermission with
    Allowed + scope fields + one-way narrowing is the P1 filing at
    Scoped Permission + RolePermission base shapes for Foundation overlays #43. The matrix is the input to the resolver; ResolvedAccess
    is the output. Different ask.
  5. The multi-tenant Tenant / (email, tenant) invariant — P1
    filing at Multi-tenant Tenant shape + scalar TenantId + (email, tenant) composite identity #41.
  6. A new Wangkanai.Federation.Claims package — target the existing
    wangkanai/federation repo unless upstream indicates a split.
  7. A timeline commitment from RiverSync — RiverSync plans to adopt
    these shapes in Common.Domain once published, on its own internal
    sequencing (governed separately, without notice).

References

RiverSync artifacts (cited verbatim above)

  • Account/src/Application/Authorization/AccessScope.cs:28-30 — abstract record + private ctor (closed set by design).
  • Account/src/Application/Authorization/AccessScope.cs:38, 48, 58OrgWide / Region(Id) / Site(Id) sealed nested records.
  • Account/src/Application/Authorization/AccessScope.cs:64-81 — resolver-only construction (factories).
  • Account/src/Application/Authorization/AccessScope.cs:83-125Intersect truth table.
  • Account/src/Application/Authorization/AccessScope.cs:109-117 — Region ∩ Site purity note.
  • Account/src/Application/Authorization/ResolvedAccess.cs:53-57 — four-component record signature.
  • Account/src/Application/Authorization/ResolvedAccess.cs:65-69Empty sentinel.
  • Account/src/Domain/Authorization/Application.cs:9(RoleName, AppKey) unique-rule.
  • Account/src/Domain/Authorization/Application.cs:21-22 — AppKey string contract (per-app discriminator).

Research anchors

  • agile/research/009-refactoring-shared-federation-domain-from-account/report.md §8 P2 row, line 115 — the framing for this P2 ask.
  • agile/research/009-refactoring-shared-federation-domain-from-account/report.md §7 Option A, line 65 — the recommended layering (extract reusable value types in Common; keep full persisted entities in Account).
  • agile/research/009-refactoring-shared-federation-domain-from-account/report.md §6 — analysis of the current OSS state vs. RiverSync usage.

Sister filings (this epic)

Local context

What this issue is NOT promising

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