Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c79b9cd
Path-less queries go live on partitioned Postgres — revocations reach…
rbuergi Aug 7, 2026
eb2934e
Merge remote-tracking branch 'origin/main' into fix/697-live-membersh…
rbuergi Aug 7, 2026
84ff7c1
Merge remote-tracking branch 'origin/main' into fix/697-live-membersh…
rbuergi Aug 8, 2026
15c659b
Merge remote-tracking branch 'origin/main' into fix/697-live-membersh…
rbuergi Aug 8, 2026
e182550
Merge remote-tracking branch 'origin/main' into fix/697-live-membersh…
rbuergi Aug 8, 2026
1257109
Merge remote-tracking branch 'origin/main' into fix/697-live-membersh…
rbuergi Aug 8, 2026
28faf00
A new partition was invisible to every path-less query until a timer …
rbuergi Aug 8, 2026
887b1d5
Merge remote-tracking branch 'origin/main' into fix/697-live-membersh…
rbuergi Aug 8, 2026
8831a87
TEMPORARY: make the CI-only fan-out failure name its own cause
rbuergi Aug 8, 2026
7eb0801
TEMPORARY: add the fresh-query discriminator to the CI fan-out dump
rbuergi Aug 8, 2026
92ad5b1
TEMPORARY: dump the access projection the fan-out gates on
rbuergi Aug 8, 2026
b633cfc
Merge remote-tracking branch 'origin/main' into fix/697-live-membersh…
rbuergi Aug 8, 2026
e4051e6
Remove the temporary CI diagnostics — they answered their question
rbuergi Aug 8, 2026
4a0c277
Merge origin/main
rbuergi Aug 8, 2026
8d566f3
Merge origin/main
rbuergi Aug 8, 2026
9b2a56b
One partition with no permission table darkened the cross-schema unio…
rbuergi Aug 9, 2026
3f7d901
The crippled partition drops itself again
rbuergi Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
Name: Removing someone from a group takes effect immediately
Category: What's New
Description: Group membership changes now reach permissions in a running portal — a revoked member loses access at once instead of keeping it until the next restart.
Icon: ShieldKeyhole
---

# Removing someone from a group takes effect immediately

Taking someone out of a group used to look like it worked and not actually work.
Search stopped listing them, the Effective Access view showed the grant as gone —
but the person could still open the records the group gave them access to, and they
kept that access until the portal was next restarted. Adding someone to a group had
the mirror-image problem: the new member saw nothing new until a restart.

Permissions resolve a person's groups from a search that deliberately spans every
partition, because a group and the grant that mentions it often live in different
places. That kind of search returned one snapshot and then stopped listening, so the
answer a running portal held was the answer from the moment it started. Membership
changes landed in the database and were simply never looked at again. Access that
should have been withdrawn stayed open — the direction that matters most.

Cross-partition searches are now live: they deliver the first snapshot and then keep
delivering additions, changes and removals as they happen. Joining a group opens the
group's records straight away, and leaving it closes them straight away, with no
restart and nothing to re-run.

The second half of the same problem was newer spaces. The list of partitions a
cross-partition search covers was refreshed on a timer, so a space created in the
last half-minute could be missing from it — and because a live search only takes
another look when something changes, a membership written into a space that young
was checked once against a list that did not include it and then never again. A new
space now joins that list the moment it is created, so it participates in live
results from its first second.

A third problem hid behind the same symptom, and it was the widest: a single partition
that carried no permission data of its own could silently empty every signed-in user's
cross-partition results. Searching across partitions asks each one "may this person read
this?", and a partition with nothing to answer from made the whole question unanswerable
— so the answer came back as "nothing found", for every partition at once, however
healthy the rest were. Such a partition is now left out of the question instead of
ending it, which changes nothing about what you are allowed to see: a partition with no
permission data never granted anyone access to begin with.

If you worked around this by restarting the portal after changing group membership,
or by granting people directly instead of through a group because group changes
"didn't take", neither workaround is needed any more.
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@ public PostgreSqlCrossSchemaQueryProvider(
"public", "information_schema", "pg_catalog", "pg_toast"
};

/// <summary>
/// True when <paramref name="schema"/> belongs in <c>public.searchable_schemas</c> — the SAME
/// predicate the discovery sync applies, exposed so the ONE place that provisions a partition
/// (<see cref="PostgreSqlPartitionStorageProvider.EnsurePartitionProvisioned"/>) can register
/// the new schema in the registry immediately instead of waiting for the throttled poll.
///
/// <para>🚨 It must stay the same predicate, not a second copy: registering an EXCLUDED schema
/// (notably <c>auth</c>, the access-object mirror) would surface every access object twice in
/// the cross-schema UNION until the next sync deleted it again.</para>
/// </summary>
internal static bool IsSearchableSchema(string? schema)
=> !string.IsNullOrEmpty(schema)
&& !ExcludedSchemas.Contains(schema)
&& !schema.EndsWith("_versions", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Syncs the searchable_schemas table by querying information_schema for
/// schemas that contain a <c>mesh_nodes</c> table. Same SQL the legacy
Expand Down Expand Up @@ -204,6 +219,37 @@ FROM information_schema.tables
return present;
}

/// <summary>
/// The subset of <paramref name="candidates"/> that actually own <paramref name="tableName"/>.
///
/// <para>Deliberately NOT <see cref="GetSchemasWithTableAsync"/>: that one starts from
/// <c>public.searchable_schemas</c> and so can only ever return REGISTERED partitions. The
/// fan-out's partition-pinned fast path builds a one-element schema list without consulting
/// that registry, so intersecting with it silently classifies a perfectly good partition as
/// "not access-controlled" and drops it from the union — granted cross-partition nodes vanish.
/// Ask about exactly the schemas in play.</para>
/// </summary>
internal async Task<IReadOnlyList<string>> GetSchemasHavingTableAsync(
IReadOnlyList<string> candidates, string tableName, CancellationToken ct = default)
{
if (candidates.Count == 0 || string.IsNullOrEmpty(tableName))
return [];
var present = new List<string>(candidates.Count);
await using var cmd = _dataSource.CreateCommand(
"""
SELECT DISTINCT table_schema
FROM information_schema.tables
WHERE table_name = $1
AND table_schema = ANY($2)
""");
cmd.Parameters.AddWithValue(tableName);
cmd.Parameters.AddWithValue(candidates.ToArray());
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
present.Add(reader.GetString(0));
return present;
}

/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetSearchableSchemasAsync(CancellationToken ct = default)
{
Expand Down Expand Up @@ -550,9 +596,23 @@ public async IAsyncEnumerable<MeshNode> QueryAcrossSchemasAsync(
? activityUserId!.ToLowerInvariant().Replace("\"", "\"\"") // quoted-identifier escape
: null;

// Which of THESE schemas actually carry `user_effective_permissions` — the only per-schema
// relation the access clause names. A schema without it (a partition provisioned by an
// older/foreign path, a static content import) turns the clause into a reference to a
// missing relation, Postgres fails to PLAN the whole UNION with 42P01, and the reader below
// absorbs 42P01 as "no rows" — so ONE such partition returned an empty result for every
// authenticated user's unscoped query. Resolved directly against the schemas in play (NOT
// via GetSchemasWithTableAsync, which intersects `searchable_schemas` and so would drop the
// pinned fast path, whose schema is never registered there).
var accessControlledSchemas = string.IsNullOrEmpty(userId)
? null
: await GetSchemasHavingTableAsync(schemas, "user_effective_permissions", ct)
.ConfigureAwait(false);

var generator = new PostgreSqlSqlGenerator();
var (sql, parameters) = generator.GenerateCrossSchemaSelectQuery(
query, schemas, userId, tableName, activityUserId, contentSchemas, activityUserSchema);
query, schemas, userId, tableName, activityUserId, contentSchemas, activityUserSchema,
accessControlledSchemas);

_logger?.LogInformation(
"[CrossSchema] Satellite query: table={Table}, schemas={Count}, contentSchemas={ContentCount}, userId={User}, source={Source}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,34 @@ await PostgreSqlSchemaInitializer.CreateSatelliteTablesAsync(
ddlDs, schemaOptions, extraTables, ct).ConfigureAwait(false);
}

// 🚨 Register the brand-new partition in the CENTRAL registry (public.searchable_schemas)
// as part of provisioning — never leave it to the discovery poll.
//
// public.searchable_schemas is the schema list every PATH-LESS (cross-schema) query fans
// out over. Its only other writer is PostgreSqlCrossSchemaQueryProvider.SyncSearchableSchemasAsync,
// which is throttled to one run per SyncTtl (30 s) and only runs when a fan-out query asks
// for the list. So a partition provisioned within that window was invisible to every
// path-less query — and for a LIVE path-less query that is not a delay, it is permanent
// loss: the live re-query fires only on a change notification, so a write that lands in the
// new partition inside the window is re-queried ONCE, against a schema list that cannot
// contain it, and nothing ever triggers another look. That is the fail-OPEN half of #697
// reappearing one layer down — a GroupMembership created in a just-provisioned Space never
// reached $security-memberships (repro: GroupMembershipLiveRevocationTests
// .PathLessNodeTypeQuery_DeliversLiveAddedAndRemoved, which opens the query BEFORE the
// partition exists exactly to pin this ordering).
//
// We KNOW the partition exists here — this is the one place that creates it — so the
// registry is written from knowledge, not rediscovered by a poll. The poll remains for
// partitions provisioned by OTHER processes. Runs AFTER the DDL commits so a concurrent
// sync can never observe the row without the schema.
if (PostgreSqlCrossSchemaQueryProvider.IsSearchableSchema(schema))
{
await using var registerCmd = _baseDataSource.CreateCommand(
"INSERT INTO public.searchable_schemas (schema_name) VALUES (@schema) ON CONFLICT DO NOTHING");
registerCmd.Parameters.AddWithValue("schema", schema);
await registerCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}

_schemasInitialized.TryAdd(schema, 0);
if (!string.IsNullOrEmpty(def.Namespace))
_registeredPartitions[def.Namespace] = def;
Expand Down
Loading
Loading