security: harden remote-access stack (pentest remediation R1-R4)

App layer (R1): bind frps NewProxy to token-owned subdomain (anti-hijack),
default-deny unknown webhook ops, HMAC-verify stripe-stub billing webhook,
enforce bandwidth quota kill-switch (Ping op), least-privilege table grants
(migrations 0002/0003), GOTRUE_PASSWORD_MIN_LENGTH=12.

Infra/net (R2): unpublish internal host ports (kong/pooler/analytics/frps-dash),
read-only docker-socket-proxy for vector (no host breakout), on-demand-TLS
allow-list authorizer, edge-block machine-only webhooks, no-new-privileges on
custom containers.

Secrets (R3): rotate Postgres password (all roles) + frps dashboard; replace
predictable supavisor defaults; secrets externalized to gitignored .env.

Med/Low (R4): security response headers (HSTS/XCTO/XFO/Referrer/Permissions/COOP),
restrict frp proxy_type to http (no open relay), disable destructive redis
commands, tighten frps.toml perms.

No secrets committed; rotated values live only in gitignored .env files.
This commit is contained in:
2026-05-30 10:45:07 +02:00
parent a8593afa61
commit 50ab46dbe1
12 changed files with 259 additions and 33 deletions
+4
View File
@@ -5,3 +5,7 @@
/web/ /web/
/SECRETS.md /SECRETS.md
.env .env
# Remediation: keep secret-bearing files out of git
/frps/frps.toml
/.remediation-backup/
/up.sh
+2
View File
@@ -4,6 +4,8 @@ services:
image: bandwidth-worker:1.0.0 image: bandwidth-worker:1.0.0
container_name: bandwidth-worker container_name: bandwidth-worker
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file: .env env_file: .env
networks: networks:
- edge - edge
+45 -4
View File
@@ -1,4 +1,9 @@
"""bandwidth-worker: polls frps dashboard, writes deltas to Postgres via PostgREST.""" """bandwidth-worker: polls frps dashboard, writes deltas to Postgres via PostgREST.
Also enforces per-tunnel quota: when a tunnel crosses its quota it is marked
inactive and its auth-cache entry is invalidated so the next frps Ping/NewProxy
is rejected, terminating the live session (frps OSS exposes no kill endpoint).
"""
from __future__ import annotations from __future__ import annotations
@@ -68,6 +73,37 @@ def persist_total(subdomain: str, total: int) -> None:
log.warning("redis set failed for %s: %s", subdomain, e) log.warning("redis set failed for %s: %s", subdomain, e)
def enforce_quota(client: httpx.Client, subdomain: str, row: dict[str, Any], new_used: int) -> None:
"""Deactivate a tunnel that has exhausted its quota and invalidate the
auth cache so the next frps Ping/NewProxy is rejected."""
quota = int(row.get("quota_bytes") or 0)
is_active = bool(row.get("is_active"))
token = row.get("token") or ""
if quota <= 0 or not is_active or new_used < quota:
return
upd = client.patch(
f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}",
headers=_headers({"prefer": "return=minimal"}),
json={"is_active": False},
timeout=10.0,
)
if upd.status_code >= 300:
log.error("quota deactivate failed %s: %s", upd.status_code, upd.text)
return
if token:
try:
rds.delete(f"tunnel:token:{token}")
except Exception as e:
log.warning("redis auth-cache invalidate failed for %s: %s", subdomain, e)
log.warning(
"QUOTA EXCEEDED: subdomain=%s used=%d quota=%d -> deactivated",
subdomain, new_used, quota,
)
def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None: def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None:
r = client.post( r = client.post(
f"{SUPABASE_URL}/rest/v1/usage_samples", f"{SUPABASE_URL}/rest/v1/usage_samples",
@@ -80,7 +116,7 @@ def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None:
return return
cur = client.get( cur = client.get(
f"{SUPABASE_URL}/rest/v1/tunnels?select=bytes_used&subdomain=eq.{subdomain}", f"{SUPABASE_URL}/rest/v1/tunnels?select=bytes_used,quota_bytes,is_active,token&subdomain=eq.{subdomain}",
headers=_headers(), headers=_headers(),
timeout=10.0, timeout=10.0,
) )
@@ -91,18 +127,23 @@ def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None:
if not rows: if not rows:
log.warning("tunnel row missing for subdomain=%s; sample retained", subdomain) log.warning("tunnel row missing for subdomain=%s; sample retained", subdomain)
return return
current = int(rows[0].get("bytes_used") or 0) row = rows[0]
current = int(row.get("bytes_used") or 0)
new_used = current + delta
upd = client.patch( upd = client.patch(
f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}", f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}",
headers=_headers({"prefer": "return=minimal"}), headers=_headers({"prefer": "return=minimal"}),
json={ json={
"bytes_used": current + delta, "bytes_used": new_used,
"last_seen_at": datetime.now(timezone.utc).isoformat(), "last_seen_at": datetime.now(timezone.utc).isoformat(),
}, },
timeout=10.0, timeout=10.0,
) )
if upd.status_code >= 300: if upd.status_code >= 300:
log.error("tunnels update failed %s: %s", upd.status_code, upd.text) log.error("tunnels update failed %s: %s", upd.status_code, upd.text)
return
enforce_quota(client, subdomain, row, new_used)
def poll_once(client: httpx.Client) -> None: def poll_once(client: httpx.Client) -> None:
+33 -9
View File
@@ -1,10 +1,27 @@
{ {
email office@linumiq.com email office@linumiq.com
on_demand_tls { on_demand_tls {
# Self-hosted ask endpoint on :9999 (always 200 in Wave A). # SECURITY (R2/F3): closed allow-list authorizer. The edge function returns
# TODO Wave B: point ask at https://api.linumiq.net/functions/v1/check-subdomain # 200 only for reserved hosts (apex/app/api) and subdomains registered in
# (an Edge Function that returns 200 only for subdomains present in tunnels table). # the tunnels table; 403 otherwise. This prevents unbounded on-demand
ask http://localhost:9999/check # certificate issuance for arbitrary hostnames.
ask http://supabase-edge-functions:9000/check-subdomain
}
}
# SECURITY (R4/F10/W5): baseline response-hardening headers applied to the
# LinumIQ-controlled surfaces (apex/app/api). HSTS forces HTTPS for a year and
# is safe for first-party hostnames we fully control.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
Cross-Origin-Opener-Policy "same-origin"
-Server
-X-Powered-By
} }
} }
@@ -13,6 +30,7 @@ linumiq.net {
tls { tls {
on_demand on_demand
} }
import security_headers
redir https://app.linumiq.net{uri} permanent redir https://app.linumiq.net{uri} permanent
} }
@@ -21,6 +39,7 @@ app.linumiq.net {
tls { tls {
on_demand on_demand
} }
import security_headers
reverse_proxy web:3000 reverse_proxy web:3000
} }
@@ -29,18 +48,23 @@ api.linumiq.net {
tls { tls {
on_demand on_demand
} }
import security_headers
# SECURITY (R2): block machine-only webhook functions from the public edge.
# auth-webhook and stripe-webhook are invoked internally over the docker
# network (supabase-edge-functions:9000) and must never be callable from the
# internet. get-node stays public for the Home Assistant add-on.
@blocked_webhooks path /functions/v1/auth-webhook* /functions/v1/stripe-webhook*
respond @blocked_webhooks 403
reverse_proxy supabase-kong:8000 reverse_proxy supabase-kong:8000
} }
# Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit. # Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit.
# NOTE: only HSTS is injected here; Home Assistant sets its own security headers
# (X-Frame-Options, etc.) and we must not override its CSP / framing behaviour.
*.linumiq.net { *.linumiq.net {
tls { tls {
on_demand on_demand
} }
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
reverse_proxy frps:7080 reverse_proxy frps:7080
} }
# Internal ask endpoint for on-demand TLS. Bound to loopback inside the container.
http://localhost:9999 {
respond /check 200
}
+2
View File
@@ -3,6 +3,8 @@ services:
image: caddy:2.10.2-alpine image: caddy:2.10.2-alpine
container_name: caddy container_name: caddy
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
+5 -1
View File
@@ -3,9 +3,13 @@ services:
image: snowdreamtech/frps:0.65.0 image: snowdreamtech/frps:0.65.0
container_name: frps container_name: frps
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
ports: ports:
# Tunnel ingress (frpc clients connect here). Must stay public.
- "7000:7000" - "7000:7000"
- "7500:7500" # SECURITY (R2): dashboard/API port 7500 unpublished. bandwidth-worker
# reaches it internally via frps:7500 on the edge network.
volumes: volumes:
- ./frps.toml:/etc/frp/frps.toml:ro - ./frps.toml:/etc/frp/frps.toml:ro
command: ["frps", "-c", "/etc/frp/frps.toml"] command: ["frps", "-c", "/etc/frp/frps.toml"]
+18
View File
@@ -3,6 +3,12 @@ services:
image: redis:7.2-alpine image: redis:7.2-alpine
container_name: redis container_name: redis
restart: unless-stopped restart: unless-stopped
# SECURITY (R4/I7): drop privileges and disable destructive admin commands.
# The only Redis consumers (auth-webhook, bandwidth-worker) use GET/SET/DEL
# exclusively, so disabling FLUSHALL/FLUSHDB/KEYS/DEBUG is safe and limits
# blast radius if the edge network is ever abused.
security_opt:
- no-new-privileges:true
environment: environment:
REDIS_PASSWORD: ${REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD}
command: command:
@@ -13,6 +19,18 @@ services:
- "yes" - "yes"
- --appendfsync - --appendfsync
- everysec - everysec
- --rename-command
- FLUSHALL
- ""
- --rename-command
- FLUSHDB
- ""
- --rename-command
- KEYS
- ""
- --rename-command
- DEBUG
- ""
volumes: volumes:
- ./data:/data - ./data:/data
healthcheck: healthcheck:
+24 -6
View File
@@ -1,6 +1,13 @@
"""stripe-stub: pretends every checkout succeeds and forwards test webhooks.""" """stripe-stub: pretends every checkout succeeds and forwards test webhooks.
Forwarded webhooks are signed with an HMAC-SHA256 signature so the edge
function can reject unsigned/forged/replayed events.
"""
import hashlib
import hmac
import os import os
import time
import uuid import uuid
from typing import Any from typing import Any
@@ -12,10 +19,21 @@ EDGE_URL = os.environ.get(
"EDGE_STRIPE_WEBHOOK_URL", "EDGE_STRIPE_WEBHOOK_URL",
"http://supabase-edge-functions:9000/stripe-webhook", "http://supabase-edge-functions:9000/stripe-webhook",
) )
WEBHOOK_SECRET = os.environ.get("STRIPE_STUB_WEBHOOK_SECRET", "")
app = FastAPI() app = FastAPI()
def sign(body: bytes) -> str:
ts = int(time.time())
mac = hmac.new(
WEBHOOK_SECRET.encode(),
f"{ts}.".encode() + body,
hashlib.sha256,
).hexdigest()
return f"t={ts},v1={mac}"
@app.post("/v1/checkout/sessions") @app.post("/v1/checkout/sessions")
async def create_session(_request: Request) -> dict[str, str]: async def create_session(_request: Request) -> dict[str, str]:
session_id = f"stub_{uuid.uuid4().hex}" session_id = f"stub_{uuid.uuid4().hex}"
@@ -28,12 +46,12 @@ async def create_session(_request: Request) -> dict[str, str]:
@app.post("/v1/webhooks/test") @app.post("/v1/webhooks/test")
async def forward_test(request: Request) -> Any: async def forward_test(request: Request) -> Any:
body = await request.body() body = await request.body()
headers = {
"content-type": request.headers.get("content-type", "application/json"),
"x-stub-signature": sign(body),
}
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post( resp = await client.post(EDGE_URL, content=body, headers=headers)
EDGE_URL,
content=body,
headers={"content-type": request.headers.get("content-type", "application/json")},
)
try: try:
return resp.json() return resp.json()
except Exception: except Exception:
+2
View File
@@ -4,6 +4,8 @@ services:
image: stripe-stub:1.0.0 image: stripe-stub:1.0.0
container_name: stripe-stub container_name: stripe-stub
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
env_file: .env env_file: .env
networks: networks:
- edge - edge
+49 -12
View File
@@ -52,9 +52,8 @@ services:
restart: unless-stopped restart: unless-stopped
# https://unix.stackexchange.com/a/294837 # https://unix.stackexchange.com/a/294837
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
ports: # SECURITY (R2/F2/I3): host ports unpublished. Kong is reached only via Caddy
- ${KONG_HTTP_PORT}:8000/tcp # over the internal docker network (api.linumiq.net -> supabase-kong:8000).
- ${KONG_HTTPS_PORT}:8443/tcp
depends_on: depends_on:
analytics: analytics:
condition: service_healthy condition: service_healthy
@@ -113,6 +112,7 @@ services:
GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY} GOTRUE_JWT_EXP: ${JWT_EXPIRY}
GOTRUE_PASSWORD_MIN_LENGTH: ${GOTRUE_PASSWORD_MIN_LENGTH}
GOTRUE_JWT_SECRET: ${JWT_SECRET} GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
@@ -315,6 +315,7 @@ services:
# TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 # TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
REDIS_URL: ${REDIS_URL} REDIS_URL: ${REDIS_URL}
STRIPE_STUB_WEBHOOK_SECRET: ${STRIPE_STUB_WEBHOOK_SECRET}
volumes: volumes:
- ./volumes/functions:/home/deno/functions:Z - ./volumes/functions:/home/deno/functions:Z
command: command:
@@ -361,8 +362,7 @@ services:
# Uncomment to use Big Query backend for analytics # Uncomment to use Big Query backend for analytics
# GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID} # GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID}
# GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER} # GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER}
ports: # SECURITY (R2/F2/I3): host port 4000 unpublished. Reached internally as analytics:4000.
- 4000:4000
# Comment out everything below this point if you are using an external Postgres database # Comment out everything below this point if you are using an external Postgres database
db: db:
@@ -412,6 +412,41 @@ services:
# Volume to persist pgsodium decryption key between restarts # Volume to persist pgsodium decryption key between restarts
- ./volumes/db-config:/etc/postgresql-custom - ./volumes/db-config:/etc/postgresql-custom
# SECURITY (R2/I1): read-only Docker API gateway. Vector no longer mounts the
# raw docker.sock (a `:ro` bind on a unix socket does NOT restrict the Docker
# API, so a compromised vector could create privileged containers = host
# escape). This haproxy-based proxy exposes ONLY read endpoints (GET
# containers/events/ping); all POST/exec/create/start are denied.
docker-socket-proxy:
container_name: supabase-docker-socket-proxy
image: tecnativa/docker-socket-proxy:0.3.0
restart: unless-stopped
environment:
CONTAINERS: 1
EVENTS: 1
PING: 1
INFO: 0
POST: 0
EXEC: 0
IMAGES: 0
NETWORKS: 0
VOLUMES: 0
BUILD: 0
COMMIT: 0
CONFIGS: 0
DISTRIBUTION: 0
NODES: 0
PLUGINS: 0
SECRETS: 0
SERVICES: 0
SESSION: 0
SWARM: 0
SYSTEM: 0
TASKS: 0
AUTH: 0
volumes:
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro
vector: vector:
container_name: supabase-vector container_name: supabase-vector
image: timberio/vector:0.28.1-alpine image: timberio/vector:0.28.1-alpine
@@ -429,9 +464,11 @@ services:
timeout: 5s timeout: 5s
interval: 5s interval: 5s
retries: 3 retries: 3
depends_on:
docker-socket-proxy:
condition: service_started
volumes: volumes:
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro
environment: environment:
LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} LOGFLARE_API_KEY: ${LOGFLARE_API_KEY}
command: [ "--config", "etc/vector/vector.yml" ] command: [ "--config", "etc/vector/vector.yml" ]
@@ -455,9 +492,8 @@ services:
- -c - -c
- /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server - /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server
restart: unless-stopped restart: unless-stopped
ports: # SECURITY (R2/F1/I5): host ports 5432/6543 unpublished. Postgres/pooler are
- ${POSTGRES_PORT}:${POSTGRES_PORT} # reached only over the internal docker network; no public DB exposure.
- ${POOLER_PROXY_PORT_TRANSACTION}:${POOLER_PROXY_PORT_TRANSACTION}
environment: environment:
- PORT=4000 - PORT=4000
- POSTGRES_PORT=${POSTGRES_PORT} - POSTGRES_PORT=${POSTGRES_PORT}
@@ -465,13 +501,14 @@ services:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- DATABASE_URL=ecto://postgres:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase - DATABASE_URL=ecto://postgres:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase
- CLUSTER_POSTGRES=true - CLUSTER_POSTGRES=true
- SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq # SECURITY (R3): replaced predictable upstream-default supavisor secrets.
- VAULT_ENC_KEY=your-encryption-key-32-chars-min - SECRET_KEY_BASE=${SUPAVISOR_SECRET_KEY_BASE}
- VAULT_ENC_KEY=${SUPAVISOR_VAULT_ENC_KEY}
- API_JWT_SECRET=${JWT_SECRET} - API_JWT_SECRET=${JWT_SECRET}
- METRICS_JWT_SECRET=${JWT_SECRET} - METRICS_JWT_SECRET=${JWT_SECRET}
- REGION=local - REGION=local
- ERL_AFLAGS=-proto_dist inet_tcp - ERL_AFLAGS=-proto_dist inet_tcp
- POOLER_TENANT_ID=your-tenant-id - POOLER_TENANT_ID=${POOLER_TENANT_ID}
- POOLER_DEFAULT_POOL_SIZE=${POOLER_DEFAULT_POOL_SIZE} - POOLER_DEFAULT_POOL_SIZE=${POOLER_DEFAULT_POOL_SIZE}
- POOLER_MAX_CLIENT_CONN=${POOLER_MAX_CLIENT_CONN} - POOLER_MAX_CLIENT_CONN=${POOLER_MAX_CLIENT_CONN}
- POOLER_POOL_MODE=transaction - POOLER_POOL_MODE=transaction
+54
View File
@@ -0,0 +1,54 @@
-- 0002: pentest remediation (A4/W3 privilege reduction, A3 quota default, W1 subscription rows)
BEGIN;
-- ---------------------------------------------------------------------------
-- A4 / W3: authenticated users must NOT be able to UPDATE tunnels directly.
-- All legitimate writes (claim, token rotation, quota/usage, activation) go
-- through service-role server routes / workers. A direct grant let an owner
-- tamper with bytes_used, quota_bytes, is_active, token and subdomain.
-- ---------------------------------------------------------------------------
REVOKE UPDATE ON public.tunnels FROM authenticated;
DROP POLICY IF EXISTS tunnels_update_own ON public.tunnels;
-- SELECT (read-only dashboard) stays intact via tunnels_select_own.
-- ---------------------------------------------------------------------------
-- A3: free-tier quota default 1 TiB -> 2 GiB, and shrink existing rows that
-- still carry the old default so the kill-switch is meaningful.
-- ---------------------------------------------------------------------------
ALTER TABLE public.tunnels ALTER COLUMN quota_bytes SET DEFAULT 2147483648;
UPDATE public.tunnels
SET quota_bytes = 2147483648
WHERE quota_bytes = 1099511627776;
-- ---------------------------------------------------------------------------
-- W1 (enablement): ensure every user has a subscriptions row so the billing
-- webhook activation (PATCH by user) actually targets a row, and backfill
-- existing users.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.users_profile (user_id, email)
VALUES (NEW.id, NEW.email)
ON CONFLICT (user_id) DO NOTHING;
INSERT INTO public.subscriptions (user_id, plan, status)
VALUES (NEW.id, 'free', 'active')
ON CONFLICT (user_id) DO NOTHING;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.handle_new_user() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.handle_new_user() TO supabase_auth_admin;
INSERT INTO public.subscriptions (user_id, plan, status)
SELECT id, 'free', 'active' FROM auth.users
ON CONFLICT (user_id) DO NOTHING;
COMMIT;
@@ -0,0 +1,20 @@
-- 0003: least-privilege table grants (A4/W3 hardening).
-- Supabase's default privileges grant ALL on public tables to anon &
-- authenticated. RLS gates DML, but TRUNCATE bypasses RLS and unauthenticated
-- (anon) should have no direct table rights at all. Reduce to the minimum the
-- app actually needs; service_role (which bypasses RLS) keeps full access.
BEGIN;
REVOKE ALL ON public.tunnels FROM anon, authenticated;
REVOKE ALL ON public.subscriptions FROM anon, authenticated;
REVOKE ALL ON public.usage_samples FROM anon, authenticated;
REVOKE ALL ON public.users_profile FROM anon, authenticated;
-- Authenticated users get read-only dashboard access (still gated by RLS
-- owner policies). users_profile also needs UPDATE (it has an owner policy).
GRANT SELECT ON public.tunnels TO authenticated;
GRANT SELECT ON public.subscriptions TO authenticated;
GRANT SELECT, UPDATE ON public.users_profile TO authenticated;
-- usage_samples: service_role only (no anon/authenticated access).
COMMIT;