Skip to main content

Multi-tenancy and managed portfolios

AiSOC separates two things that are easy to conflate: the tenant, which is a security boundary around one estate's data, and the organisation, which is the operator that manages some number of them. A managed service provider is an organisation. A single company running its own SOC is also an organisation, with a portfolio of one.

Why the operator is not a tenant​

An earlier model added tenants.parent_tenant_id — the provider was itself a tenant, and its customers were child rows. That is enough to draw a tree and not enough to run a managed service:

  • It conflates the operator with a boundary. Every consumer of current_user.tenant_id on an MSSP route asks "who is the operator" and "whose data is this" with one value, and those have to be different questions or a cross-tenant surface has nothing to enforce.
  • There is no per-analyst scoping. Every user of the parent tenant reaches every child. Providers assign analysts to accounts; the column cannot say so.
  • ON DELETE SET NULL meant deleting the parent silently detached the whole portfolio, leaving no record it had existed.

parent_tenant_id still works and still backs GET /api/v1/mssp/children, so existing deployments are unaffected. Migration 058 adds the organisation model above it and backfills from it.

The schema​

TableHolds
organizationsThe operator. home_tenant_id is the tenant its own staff sign in to.
organization_membersWho belongs to it, and with what authority.
organization_tenantsThe managed portfolio.
organization_member_tenantsWhich portfolio tenants one member may reach.

Three constraints do the work that would otherwise live in application code, and therefore hold even if that code is wrong:

  • organization_tenants is unique on tenant_id, so two providers cannot both claim one customer and "whose portfolio is this in" has one answer.
  • organization_member_tenants carries composite foreign keys onto both organization_members and organization_tenants. A grant cannot name a tenant the organisation does not manage, and releasing a tenant from the portfolio revokes every grant over it in the same statement.
  • organizations.home_tenant_id cascades. If the provider's own tenant is erased under a deletion request, the organisation goes with it — and the customers it managed survive as unclaimed tenants, because offboarding a provider is not an instruction to erase its customers.

Roles​

Two axes, kept separate because they answer different questions.

RoleReachesMay act
ownerThe whole portfolioYes, and may administer the organisation
adminThe whole portfolioYes, and may administer the organisation
operatorOnly tenants granted to themYes
viewerOnly tenants granted to themNo

An operator or viewer with no grants reaches nothing. That is the case the design is shaped around: "no scope" must never degrade into "all scopes", or every new analyst starts with the entire book of business.

Cross-tenant surfaces​

EndpointReturns
GET /api/v1/mssp/portfolioPer-tenant posture plus derived totals
GET /api/v1/mssp/portfolio/alertsOpen alerts across the portfolio
GET /api/v1/mssp/overviewPortfolio totals only
GET /api/v1/mssp/tenantsPer-tenant posture only
GET /api/v1/mssp/incidentsOpen alerts across the portfolio

Each resolves its tenant list through resolve_portfolio_scope and nowhere else, then passes it to require_scope, which raises rather than letting an aggregate run with no filter. The tenant list is bound as a query parameter, never formatted into SQL. A ?tenant_id= filter is intersected with the portfolio, so naming a tenant outside it narrows the result to nothing instead of reaching outside.

A caller who belongs to no organisation gets 403. A member whose portfolio is genuinely empty gets zeros and an empty list, plus scoped_tenants and portfolio_wide in the payload so the console can say why it is empty — "you have no tenant grants" and "your organisation manages no tenants" are different problems with different fixes.

What these endpoints do not return​

They previously returned five hardcoded companies — Acme Corp, Globex Industries, Wayne Enterprises — with invented alert counts and a health_score nobody could reproduce. Every figure is now counted from that tenant's own rows, and two consequences are worth stating plainly:

  • health_score is gone. It was an undefined composite. There is no honest way to keep the field and no honest way to compute it.
  • mttr_minutes is null when a tenant has closed no cases. It is measured from cases that tenant actually closed in the trailing 30 days. Reporting 0.0 would put the account that has done nothing at the top of the league table.
  • Seeded demo rows are counted separately as synthetic_alerts and excluded from the headline figures, so a demo install reads "0 real, 15 synthetic" rather than reporting fixtures as a customer's posture.

The console view​

/mssp reads GET /api/v1/mssp/portfolio and GET /api/v1/mssp/portfolio/alerts and shows nothing it did not receive. There is no sample-data fallback: it has four states and each one tells you which it is.

StateWhat you see
Loading"Loading portfolio…" and no figures at all
Not an operator (403)"You do not manage any tenants", with how to be added
Portfolio API failedThe failure and its message, with a retry
Empty portfolioWhether the organisation manages none, or you were granted none

The two empty cases are distinguished by portfolio_wide and scoped_tenants from the payload, because the fixes differ: one needs a tenant to accept an invitation, the other needs an owner to grant you access.

The alert feed has its own states, so a failure there does not claim the whole portfolio is down.

Columns are per-tenant open alerts, criticals, untriaged, open cases, cases past SLA, MTTR, connector health and last event time — every one counted from the tenant's own rows. An unmeasured MTTR renders as —, never 0.

There is no revenue, risk-score or analyst-allocation column. The view used to carry all three against six hardcoded companies. None of them has a source anywhere in the product, so they are gone rather than nulled: a column of blanks still implies the measurement exists.

Limits and headroom​

A provider's most expensive failure is a quiet one. A tenant that hits a cap does not raise an error anyone sees: alerts keep arriving and stop being triaged, and the customer reports it as "the AI doesn't work".

Every tenant row carries per-key headroom, and the portfolio summary counts how many tenants are in warning or exhausted. Exhaustion is logged at warning with the tenant and key.

AiSOC ships uncapped. A key with no configured ceiling reports "state": "unlimited" and "limit": null — not a default ceiling, because drawing a headroom bar against a number nobody set would put a fabricated figure on every tenant row. Set ceilings either way:

  • tenants.limits (JSONB, per tenant) overrides everything, in either direction. "unlimited" and -1 both mean no limit.
  • AISOC_DEFAULT_TENANT_LIMITS (JSON object) sets a deployment-wide default, e.g. {"connectors": 25, "seats": 50}.

Measured keys are connectors, seats, alerts_per_day and triages_per_month. Each is a count over real rows; a key that cannot be counted honestly is not offered.

Isolation​

Cross-tenant reads are the one place in the product where per-tenant filtering is deliberately relaxed, so they carry their own gates:

  • services/api/tests/test_org_scope.py runs on every PR. An empty scope refuses; a ?tenant_id= filter can only narrow; and an AST check fails the build if a new cross-tenant function is added that never calls require_scope.
  • services/api/tests/test_mssp_portfolio_isolation.py runs against live Postgres in integration.yml. It seeds two organisations plus an unmanaged tenant, asserts the outsiders' data really exists so a scoped read cannot pass vacuously, then asserts an aggregate as one operator never returns the others.

See Tenant isolation for the per-store rules that apply to ordinary single-tenant reads.