Skip to content

Per-tenant progression filter uses an unescaped LIKE: tenant ids containing '_' match other tenants' rows #5171

Description

@jeremydmiller

ProjectionProgressStatement's per-tenant filter matches progression rows with an unescaped LIKE pattern built from the tenant id, which both over-matches on tenant ids containing _ and over-matches on tenant-less identities whose shard key happens to equal the tenant id.

src/Marten/Events/Daemon/Progress/ProjectionProgressStatement.cs:82-91:

if (TenantId != null)
{
    builder.Append(whereStarted ? " and " : " where ");
    // Tenant-bearing ShardName.Identity always ends in `:{tenantId}`.
    // Match the trailing tenant suffix via LIKE — partition suffixes
    // are valid PG identifiers so they don't contain LIKE wildcards.
    builder.Append("name like ");
    builder.AppendParameter("%:" + TenantId);
}

1. _ is a LIKE wildcard

The comment's premise is wrong: _ is both a perfectly valid PostgreSQL identifier character and a LIKE single-character wildcard. Underscores in tenant ids are common.

A read scoped to tenant acme_corp builds name like '%:acme_corp', which also matches the progression rows of tenants acmeXcorp, acme-corp, acme corp — any tenant differing only in that one position. Those rows are then reported as acme_corp's progress. This is the one worth fixing: it silently blends two tenants' progression under multi-tenancy, which is the exact thing the tenant filter exists to prevent.

(% in a tenant id would be worse still, matching everything, but is far less likely to occur.)

2. A shard key equal to a tenant id also matches

ShardName.Identity is {Name}:{ShardKey} or {Name}:V{n}:{ShardKey} when there's no tenant. So a projection sliced with a custom shard key named acme produces Foo:acme / Foo:V2:acme, both of which end in :acme and match a filter for tenant acme — a store-global row attributed to a tenant.

Narrower than (1), since it needs a shard key colliding with a tenant id, but it's the same root cause: suffix-matching a string grammar instead of parsing it.

Suggested fix

Suffix-compare instead of pattern-match, which fixes both at once and keeps it index-neutral (the existing LIKE '%...' can't use an index anyway):

right(name, char_length(@tenant) + 1) = ':' || @tenant

That still admits (2), so if the shard-key collision matters, filter the parsed rows on ShardName.TenantId after materialization — ShardStateSelector already builds the row, and ShardName.TryParse distinguishes Foo:V2:acme (shard key) from Foo:All:acme (tenant) correctly. Escaping the LIKE pattern (like ... escape '\' with _ and % escaped) fixes (1) only.

Worth noting the per-tenant high-water rows (HighWaterMark:{tenant}) also match this filter and are presumably meant to — whichever fix lands should keep including them, since a tenant-scoped progression read wants that tenant's mark.

Found while looking at #5170, which is about callers doing this same suffix surgery in application code. Independent of that request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions