Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ jobs:
{
echo "NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321"
echo "DOTNET_API_URL=http://localhost:5099"
# Raw Postgres (for the #321 dotnet_app backstop test) — published on
# the runner at localhost:54322; in-container the test defaults to
# supabase-db:5432.
echo "SUPABASE_DB_HOST=localhost"
echo "SUPABASE_DB_PORT=54322"
} >> "$GITHUB_ENV"

- name: Bring up the dual-provider stack (local Supabase + .NET)
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,11 @@ services:
ASPNETCORE_ENVIRONMENT: Development
DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM: '1'
# Reach the local Supabase Postgres by service DNS (same messaging schema).
ConnectionStrings__DefaultConnection: 'Host=supabase-db;Port=5432;Database=postgres;Username=postgres;Password=${POSTGRES_PASSWORD:-your-super-secret-and-long-postgres-password}'
# Connect as the least-privilege dotnet_app role (#321), NOT postgres — so
# RLS + the #281 trigger are a live backstop. Locally it shares
# POSTGRES_PASSWORD (docker/supabase/roles.sql sets dotnet_app's password to
# it). Prod overrides this whole env with a distinct injected password.
ConnectionStrings__DefaultConnection: 'Host=supabase-db;Port=5432;Database=postgres;Username=dotnet_app;Password=${POSTGRES_PASSWORD:-your-super-secret-and-long-postgres-password}'
# Same HS256 secret GoTrue/PostgREST validate against.
SUPABASE_JWT_SECRET: ${SUPABASE_JWT_SECRET:-super-secret-jwt-token-with-at-least-32-characters-long}
CORS_ORIGINS: 'http://localhost:3000,http://localhost:3002,http://127.0.0.1:3002'
Expand Down
5 changes: 5 additions & 0 deletions docker/supabase/roles.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ ALTER ROLE authenticator WITH PASSWORD :'pgpass';
ALTER ROLE supabase_auth_admin WITH PASSWORD :'pgpass';
ALTER ROLE supabase_storage_admin WITH PASSWORD :'pgpass';
ALTER ROLE supabase_read_only_user WITH PASSWORD :'pgpass';
-- dotnet_app (#321): the least-privilege role the .NET backend logs in as. It is
-- CREATEd by the app monolithic migration (99999999999999_app_monolithic), which
-- runs before this /etc/postgresql.schema.sql hook, so it exists by now. Locally
-- it shares POSTGRES_PASSWORD; prod injects a distinct DOTNET_DB_PASSWORD.
ALTER ROLE dotnet_app WITH PASSWORD :'pgpass';

-- ── 2. _realtime schema ────────────────────────────────────────────────────
-- Not the same thing as the `realtime` schema (no underscore) that base
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ namespace MessagingApi.Controllers;
/// <summary>
/// Per-message endpoints. Re-expresses the messages RLS/trigger contract in C#:
/// C7 (read scoping), C9 (sender-only edit), C10 (15-min window), C11 (recipient
/// mark-read), C12 (soft-delete). The Supabase #281 column-guard trigger exempts
/// this backend's privileged DB connection, so these checks are the sole guard.
/// mark-read), C12 (soft-delete). Since #321 the backend connects as the
/// least-privilege dotnet_app role with the RLS actor set per request, so the
/// Supabase #281 column-guard trigger + RLS now FIRE as a live backstop — these
/// C# checks are the first layer, no longer the sole guard.
/// </summary>
[ApiController]
[Route("api/messaging")]
Expand Down
8 changes: 6 additions & 2 deletions dotnet-messaging/src/MessagingApi/Data/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ namespace MessagingApi.Data;
/// an alternative REST API over the same tables — it does NOT own or create the
/// schema (no EnsureCreated/Migrate). The Supabase monolithic migration remains
/// the single source of truth for DDL, including the concurrency-critical
/// triggers (assign_sequence_number = C13, uniq client_generated_id = C14,
/// enforce_message_update_columns = #281) which still fire for these writes.
/// triggers (assign_sequence_number = C13, uniq client_generated_id = C14) and
/// the #281 column-guard (enforce_message_update_columns). Since #321 this backend
/// connects as the least-privilege `dotnet_app` role and sets the RLS actor per
/// request (RlsActorMiddleware), so RLS + the column-guard trigger now ENFORCE —
/// a live backstop behind the controllers' C# checks, not bypassed by a superuser
/// connection.
/// </summary>
public class AppDbContext : DbContext
{
Expand Down
79 changes: 79 additions & 0 deletions dotnet-messaging/src/MessagingApi/Middleware/RlsActorMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Text.Json;
using MessagingApi.Auth;
using MessagingApi.Data;
using Microsoft.EntityFrameworkCore;

namespace MessagingApi.Middleware;

/// <summary>
/// Establishes the Postgres RLS actor for each authenticated request (#321).
///
/// The backend logs in as the least-privilege <c>dotnet_app</c> role (NOT the
/// postgres superuser), which is subject to Row-Level Security and the #281
/// column-guard trigger. Per request this opens ONE transaction and runs
/// <c>SET LOCAL ROLE authenticated</c> + <c>set_config('request.jwt.claims', …, true)</c>
/// so <c>auth.uid()</c>/<c>auth.role()</c> resolve inside the participant-scoped
/// RLS policies and the trigger — making the DB a live backstop behind the
/// controllers' C# authorization checks (belt-and-suspenders).
///
/// <para><c>SET LOCAL</c> is transaction-scoped, so the actor is discarded on
/// COMMIT/ROLLBACK and never leaks onto the next request that draws the same
/// pooled connection (Npgsql pools connections; multiplexing is off).</para>
///
/// <para>The <see cref="AppDbContext"/> is request-scoped and shared, so every
/// controller's EF query / <c>ExecuteSqlRawAsync</c> / <c>SaveChangesAsync</c>
/// enlists in this transaction automatically — no controller changes are needed.
/// A side benefit is that each request is now atomic.</para>
/// </summary>
public sealed class RlsActorMiddleware
{
private readonly RequestDelegate _next;

public RlsActorMiddleware(RequestDelegate next) => _next = next;

public async Task InvokeAsync(HttpContext context, AppDbContext db)
{
var caller = CallerContext.FromPrincipal(context.User);

// Anonymous / unauthenticated requests (e.g. /health) do no
// participant-scoped DB work — every authenticated endpoint early-returns
// Unauthorized when the caller is null. Skip the transaction + actor.
if (caller is null)
{
await _next(context);
return;
}

// Always assume the `authenticated` role — this is a user-facing API and
// dotnet_app is deliberately NOT granted service_role, so it cannot (and
// must not) SET ROLE into a BYPASSRLS role. Admin flows are expressed as
// auth.uid()-keyed RLS policies, not via service_role.
var claims = JsonSerializer.Serialize(new
{
sub = caller.UserId.ToString(),
role = "authenticated",
});

await using var tx = await db.Database.BeginTransactionAsync();

// Order: become authenticated, then publish the JWT claims. `true` =
// is_local (transaction-scoped). The claims JSON is passed as a bound
// parameter — never string-concatenated — even though `sub` is a
// server-validated GUID.
await db.Database.ExecuteSqlRawAsync("SET LOCAL ROLE authenticated");
await db.Database.ExecuteSqlRawAsync(
"SELECT set_config('request.jwt.claims', {0}, true)",
claims);

try
{
await _next(context);
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}
}
}
5 changes: 5 additions & 0 deletions dotnet-messaging/src/MessagingApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using MessagingApi.Data;
using MessagingApi.Middleware;

var builder = WebApplication.CreateBuilder(args);

Expand Down Expand Up @@ -148,6 +149,10 @@
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// Set the Postgres RLS actor per authenticated request (#321) — runs after
// auth (needs HttpContext.User) and before the controllers, so their DB work
// enlists in the actor's transaction. Anonymous requests (/health) are skipped.
app.UseMiddleware<RlsActorMiddleware>();
app.MapControllers();

app.Run();
Expand Down
2 changes: 1 addition & 1 deletion dotnet-messaging/src/MessagingApi/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=supabase-db;Port=5432;Database=postgres;Username=postgres;Password=your-super-secret-and-long-postgres-password"
"DefaultConnection": "Host=supabase-db;Port=5432;Database=postgres;Username=dotnet_app;Password=your-super-secret-and-long-postgres-password"
},
"SUPABASE_JWT_SECRET": "super-secret-jwt-token-with-at-least-32-characters-long"
}
19 changes: 19 additions & 0 deletions supabase/migrations/20251006_complete_monolithic_setup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2310,6 +2310,25 @@ GRANT ALL ON user_encryption_keys TO authenticated, service_role;
GRANT ALL ON conversation_keys TO authenticated, service_role;
GRANT ALL ON typing_indicators TO authenticated, service_role;

-- ── Least-privilege role for the .NET messaging backend (#321) ──────────────
-- The ASP.NET Core backend (dotnet-messaging/) must connect as a NON-superuser,
-- NON-owner role so RLS + the #281 column-guard trigger apply as a live backstop
-- behind its C# checks (the postgres superuser/owner bypasses both, which is why
-- the C# checks were previously the sole guard). Per request it runs
-- `SET LOCAL ROLE authenticated` + sets request.jwt.claims (the PostgREST model),
-- so auth.uid() resolves in-DB and the participant-scoped RLS policies + the
-- trigger enforce. It is granted ONLY `authenticated` (NOT service_role), so it
-- cannot SET ROLE into a BYPASSRLS role. NOINHERIT so it gains authenticated's
-- privileges only via the explicit per-request SET ROLE. The password is set
-- per-environment (docker/supabase/roles.sql locally; injected in prod) — never
-- committed here.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dotnet_app') THEN
CREATE ROLE dotnet_app LOGIN NOINHERIT NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
END IF;
END $$;
GRANT authenticated TO dotnet_app;

-- ============================================================================
-- PART 10.5: GROUP CHAT SUPPORT (Feature 010)
-- ============================================================================
Expand Down
Loading
Loading