OData host-feed — cross-tenant BI for host operators
The standard OData feed is tenant-scoped: a tenant analyst sees only their own tenant’s rows. That is correct for the vast majority of BI use cases — and it is the framework’s explicit security model.
A small set of legitimate use cases need data across tenants, and they are owned by the host (the SaaS operator), not by any individual tenant:
- Finance ops — MRR / ARR rolled up across the entire customer base.
- Compliance —
AuditEntriesorPrivacyRequestscross-tenant for ISO 27001 A.12.4 audit trails. - Capacity planning —
UsageAggregatescross-tenant to anticipate scaling needs. - Product analytics — churn analysis on
Subscriptionsacross tenants by plan.
For these cases, Granit.Http.ODataExposure ships a second mount:
MapGranitODataHostEndpoints — same module, separate URL, separate
options surface, and three strict-config gates that make every cross-tenant
exposure an explicit, auditable opt-in.
Mounting the host-feed
Section titled “Mounting the host-feed”// Tenant-feed (unchanged) — every analyst sees their own tenant's rowsapp.MapGranitODataEndpoints("/api/v1/odata", opts =>{ opts.EntitySet<Invoice, InvoiceQueryDefinition>("Invoices") .RequirePermission("OData.Invoicing.Invoices.Read") .ExpandWhitelist("Customer");});
// Host-feed — separate URL, separate $metadata container, separate rate limitapp.MapGranitODataHostEndpoints("/api/v1/odata/host", opts =>{ opts.EntitySet<Tenant, TenantQueryDefinition>("Tenants") .RequirePermission("OData.Host.Platform.Tenants.Read") // MUST be MultiTenancySides.Host .DisableExpand();
opts.EntitySet<Invoice, InvoiceQueryDefinition>("InvoicesAllTenants") .RequirePermission("OData.Host.Invoicing.Invoices.Read") .AcknowledgeCrossTenantExposure(q => q.IgnoreQueryFilters([GranitFilterNames.MultiTenant])) // explicit per-query bypass .ExpandWhitelist("Customer");});Two characteristics of this code are deliberate:
- The options type is
ODataHostExposureOptions— distinct from the tenant-feed’sODataExposureOptions. A configuration block intended for one mount cannot accidentally be applied to the other; the C# compiler rejects the mismatch. - The bypass is a lambda the host writes at the call site — not a flag
on the builder. The framework module never references EF Core, and the
explicit “I know what I’m doing” lives in actual code that a reviewer
reads, not in a property name buried under several
withclauses.
The three strict-config gates
Section titled “The three strict-config gates”Every host-feed EntitySet must pass three gates at startup. Failing any
gate throws InvalidOperationException from MapGranitODataHostEndpoints,
listing every misconfigured set in a single message.
Gate 1 — Permission must resolve to MultiTenancySides.Host
Section titled “Gate 1 — Permission must resolve to MultiTenancySides.Host”The host-feed validator resolves the EntitySet’s RequirePermission name
through IPermissionDefinitionManager at startup. The resolved
PermissionDefinition.MultiTenancySides MUST equal Host. Tenant and
Both are rejected; an unregistered permission name is rejected too.
This rule prevents host-feed access from leaking through tenant-side role
inheritance — a permission marked Both could be granted to a tenant role
and unintentionally unlock cross-tenant data.
// In your *PermissionDefinitionProvider:PermissionGroup hostGroup = context.AddGroup( "OData.Host.Invoicing", new LocalizableString("PermissionGroup:OData.Host.Invoicing"));
hostGroup.AddPermission( "OData.Host.Invoicing.Invoices.Read", new LocalizableString("Permission:OData.Host.Invoicing.Invoices.Read"), multiTenancySide: MultiTenancySides.Host);Naming convention: OData.Host.{Module}.{Entity}.Read — the .Host
segment makes the cross-tenant scope visible in admin UIs and audit logs.
Gate 2 — No anonymous access
Section titled “Gate 2 — No anonymous access”ODataHostEntitySetBuilder<TEntity> does not expose an
AllowAnonymousAccess() method. Every host-feed set is gated, period —
the public-feed scenario (e.g. tenant-agnostic reference data) belongs on
the tenant-feed, not on a feed that is meant to be cross-tenant.
Gate 3 — IMultiTenant entities require AcknowledgeCrossTenantExposure
Section titled “Gate 3 — IMultiTenant entities require AcknowledgeCrossTenantExposure”When the EntitySet’s entity implements IMultiTenant, the host MUST call
AcknowledgeCrossTenantExposure(...) and supply a per-query bypass
lambda. Without it, the framework’s tenant filter
(tenantId == currentTenant.Id) returns no rows for a tenantless caller —
fail-closed, but confusing — so the validator throws at startup with a
direct pointer to the missing call.
.AcknowledgeCrossTenantExposure(q => q.IgnoreQueryFilters([GranitFilterNames.MultiTenant]))Why a host-supplied lambda rather than a flag:
- The OData module stays free of an EF Core dependency. The bypass
primitive (
IgnoreQueryFilters) lives inMicrosoft.EntityFrameworkCore; the application’sProgram.csalready references EF Core, so the lambda is written there, not in framework code. - Per-query, not AsyncLocal. The framework also exposes
IDataFilter.Disable<IMultiTenant>()for service-level bypass — but that path isAsyncLocal-based and we have an unresolved cross-request leak documented in our defensive-bypass history (#1180 / #1182 / #1185). Localising the bypass inside the request’s expression tree avoids any risk of propagation outside the OData pipeline. - The “I know what I’m doing” is explicit code. A reviewer reading the
Program.cshost-feed configuration sees the bypass on the screen — it cannot be hidden behind a flag default.
Non-IMultiTenant entities (e.g. the Tenant table itself, or any
host-scoped configuration entity) do NOT need the acknowledgement — the
framework filter does not apply to them in the first place.
Operational distinctions vs the tenant-feed
Section titled “Operational distinctions vs the tenant-feed”| Concern | Tenant-feed | Host-feed |
|---|---|---|
| Default URL prefix | /api/v1/odata | /api/v1/odata/host |
| OData container name | Container | HostContainer |
| Rate-limit policy | granit-odata (recommended PartitionBy: Tenant) | granit-odata-host (recommended PartitionBy: User, wider quotas) |
feed_kind OTel tag on metrics | tenant | host |
tenant_id OTel tag on metrics | resolved tenant id | "global" (no ambient tenant) |
| Anonymous access | allowed via AllowAnonymousAccess() for reference-data feeds | never |
IMultiTenant entity allowed? | yes (default — filter applies) | yes (explicit bypass required) |
The distinct EDM container name matters more than it looks: a BI client
that mistakenly reuses one feed’s $metadata document on the other URL
sees an immediate schema mismatch, surfacing the misconfiguration loudly
instead of silently returning unexpected data.
Telemetry — auditing host-feed traffic
Section titled “Telemetry — auditing host-feed traffic”Every ODataExposureMetrics counter (granit.odata.query.rejected,
granit.odata.query.top_clamped, etc.) carries the feed_kind tag.
A dedicated audit dashboard for cross-tenant access events is a one-line
filter in Grafana / your OTel collector:
sum by (entity_set, reason) ( rate(granit_odata_query_rejected_total{feed_kind="host"}[5m]))ISO 27001 A.12.4 (event logging) is satisfied by the same telemetry that already covers the tenant-feed — no new instrumentation needed in the host application.
When to use host-feed vs Granit.Analytics
Section titled “When to use host-feed vs Granit.Analytics”Both surfaces can answer cross-tenant questions. The right choice depends on the consumer and the shape of the answer.
| Need | Use |
|---|---|
| One number (“MRR across all tenants”) inside a host admin dashboard | Granit.Analytics (MetricDefinition aggregating across tenants, surfaced via the Layer 1 KPI endpoint) |
| Tabular data for an external BI tool (Power BI / Excel / Tableau) | Host-feed (this page) |
| One number inside a tenant admin dashboard | Granit.Analytics (the standard tenant-scoped path) |
| Tabular data for a tenant analyst | Standard OData feed |
Rule of thumb: if the consumer is the application’s own UI and the value
is one (or a few) aggregated numbers per call, Granit.Analytics is the
right tool. If the consumer is an external BI tool that needs full row sets
and benefits from $filter / $select / $top composition, the OData
feed (tenant or host) is the right tool.
Connecting Power BI to the host-feed
Section titled “Connecting Power BI to the host-feed”The mechanics are identical to the tenant-feed walkthrough — Power BI Desktop’s Get Data → OData feed dialog accepts the host-feed URL as-is. Two configuration changes worth highlighting:
-
Use the host-feed
.pbidstemplate. The repo shipssamples/PowerBI/granit-showcase-host.pbidspointing at/api/v1/odata/host; replacehttps://your-app.example.comwith your deployment’s host. The container name in the discovered$metadataisHostContainer, distinct from the tenant-feed’sContainer— Power BI discovers EntitySets identically; the distinct name surfaces an immediate schema mismatch if the wrong file is used against the wrong URL. -
Sign in with a host SuperAdmin account. A tenant user’s bearer token will not carry the
OData.Host.*permissions, so the Navigator will return zero EntitySets (every set is403). Use a dedicated service principal scoped to the host realm for unattended Power BI Service refresh jobs.
See also
Section titled “See also”Granit.Http.ODataExposureREADME — fluent builder, hardening defaults, rate-limit configuration.- Standard OData feed (Power BI / Excel / Tableau) — the tenant-scoped sibling of this page.
- Analytics conventions —
MetricDefinition,DashboardDefinition, naming and pairing rules. - EPIC #1366 — Business Intelligence and the host-feed reflection #1665 that drove this design.