@nextsparkjs/core 0.1.0-beta.166 → 0.1.0-beta.168
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/useAuth.d.ts +2 -1
- package/dist/hooks/useAuth.d.ts.map +1 -1
- package/dist/hooks/useAuth.js +13 -8
- package/dist/lib/api/entity/generic-handler.d.ts.map +1 -1
- package/dist/lib/api/entity/generic-handler.js +23 -3
- package/dist/lib/auth-context.d.ts +5 -0
- package/dist/lib/auth-context.d.ts.map +1 -1
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/auth.js +28 -5
- package/dist/lib/config/app.config.d.ts.map +1 -1
- package/dist/lib/config/app.config.js +9 -1
- package/dist/lib/config/types.d.ts +17 -0
- package/dist/lib/config/types.d.ts.map +1 -1
- package/dist/lib/db.d.ts +28 -5
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +38 -16
- package/dist/lib/entities/types.d.ts +20 -3
- package/dist/lib/entities/types.d.ts.map +1 -1
- package/dist/lib/services/team-member.service.d.ts.map +1 -1
- package/dist/lib/services/team-member.service.js +2 -1
- package/dist/lib/services/team.service.d.ts.map +1 -1
- package/dist/lib/services/team.service.js +2 -2
- package/dist/lib/teams/actions.d.ts.map +1 -1
- package/dist/lib/teams/actions.js +4 -3
- package/dist/migrations/001_better_auth_and_functions.sql +70 -0
- package/dist/migrations/002_auth_tables.sql +90 -10
- package/dist/migrations/007_teams_table.sql +10 -4
- package/dist/migrations/008_team_members_table.sql +1 -1
- package/dist/migrations/009_team_invitations_table.sql +1 -1
- package/dist/migrations/010_teams_functions_triggers.sql +6 -48
- package/dist/migrations/013_billing_subscriptions.sql +61 -31
- package/dist/migrations/016_billing_events.sql +17 -3
- package/dist/migrations/017_scheduled_actions_table.sql +9 -7
- package/dist/migrations/022_rls_runtime_roles.sql +87 -0
- package/dist/styles/classes.json +1 -1
- package/dist/templates/app/api/auth/[...all]/route.ts +14 -1
- package/migrations/001_better_auth_and_functions.sql +70 -0
- package/migrations/002_auth_tables.sql +90 -10
- package/migrations/007_teams_table.sql +10 -4
- package/migrations/008_team_members_table.sql +1 -1
- package/migrations/009_team_invitations_table.sql +1 -1
- package/migrations/010_teams_functions_triggers.sql +6 -48
- package/migrations/013_billing_subscriptions.sql +61 -31
- package/migrations/016_billing_events.sql +17 -3
- package/migrations/017_scheduled_actions_table.sql +9 -7
- package/migrations/022_rls_runtime_roles.sql +87 -0
- package/package.json +2 -2
- package/scripts/db/run-migrations.mjs +13 -2
- package/templates/app/api/auth/[...all]/route.ts +14 -1
- package/templates/next.config.mjs +1 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 022 · RLS runtime roles — nextspark_app (non-owner runtime role) + grants
|
|
3
|
+
-- ============================================================================
|
|
4
|
+
-- Generic framework migration: creates the non-owner runtime role the app
|
|
5
|
+
-- connects as so RLS is actually evaluated on the app path.
|
|
6
|
+
--
|
|
7
|
+
-- WHY: by default the app connects to Postgres as the table OWNER, so RLS
|
|
8
|
+
-- policies are never evaluated on the app path (owner skips RLS unless FORCE).
|
|
9
|
+
-- This migration creates the non-owner runtime role the app connects as after
|
|
10
|
+
-- the runtime cutover. Until the cutover nothing changes for the running app:
|
|
11
|
+
-- migrations and seeds keep running as the owner.
|
|
12
|
+
--
|
|
13
|
+
-- Design decisions:
|
|
14
|
+
-- - nextspark_app is NOLOGIN here; the LOGIN credential is created per environment
|
|
15
|
+
-- at cutover time (deploy-time secret), never in a migration.
|
|
16
|
+
-- - nextspark_app is a member of `authenticated` (INHERIT): every existing policy
|
|
17
|
+
-- declared `TO authenticated` applies to it without rewriting.
|
|
18
|
+
-- - NO `FORCE ROW LEVEL SECURITY`: nextspark_app is not the owner so plain ENABLE
|
|
19
|
+
-- is enough, and FORCE would break owner-run seeds/sample-data on Supabase
|
|
20
|
+
-- (where `postgres` is owner but not superuser).
|
|
21
|
+
-- - NO `BYPASSRLS` role here: that attribute requires superuser (not available
|
|
22
|
+
-- on Supabase). The service context for machine actors (webhooks, scheduled
|
|
23
|
+
-- actions) is a CORE/environment workstream: the app uses a separate service
|
|
24
|
+
-- connection (DATABASE_SERVICE_URL) for system operations.
|
|
25
|
+
-- - `anon` gets an explicit REVOKE + default privileges revoke: on Supabase the
|
|
26
|
+
-- default privileges grant to anon automatically; abstaining is not enough.
|
|
27
|
+
--
|
|
28
|
+
-- Ordering: runs after all core table DDL (<= 021) so `GRANT ... ON ALL TABLES`
|
|
29
|
+
-- covers every existing table; `ALTER DEFAULT PRIVILEGES` covers tables created
|
|
30
|
+
-- later (theme/entity migrations) by the same migration owner.
|
|
31
|
+
|
|
32
|
+
-- ----------------------------------------------------------------------------
|
|
33
|
+
-- 1. Runtime role
|
|
34
|
+
-- ----------------------------------------------------------------------------
|
|
35
|
+
DO $$
|
|
36
|
+
BEGIN
|
|
37
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nextspark_app') THEN
|
|
38
|
+
CREATE ROLE nextspark_app NOLOGIN NOINHERIT;
|
|
39
|
+
END IF;
|
|
40
|
+
END $$;
|
|
41
|
+
|
|
42
|
+
-- INHERIT membership in `authenticated` so policies `TO authenticated` apply.
|
|
43
|
+
ALTER ROLE nextspark_app INHERIT;
|
|
44
|
+
GRANT authenticated TO nextspark_app;
|
|
45
|
+
|
|
46
|
+
-- Allow the migration/validation user to SET ROLE nextspark_app (e.g. an RLS
|
|
47
|
+
-- isolation test suite can run its checks as this role without a LOGIN credential).
|
|
48
|
+
DO $$
|
|
49
|
+
BEGIN
|
|
50
|
+
EXECUTE format('GRANT nextspark_app TO %I', current_user);
|
|
51
|
+
EXCEPTION WHEN OTHERS THEN
|
|
52
|
+
RAISE NOTICE 'GRANT nextspark_app TO current_user skipped: %', SQLERRM;
|
|
53
|
+
END $$;
|
|
54
|
+
|
|
55
|
+
-- ----------------------------------------------------------------------------
|
|
56
|
+
-- 2. Grants for nextspark_app (RLS does the row filtering; grants gate the tables)
|
|
57
|
+
-- ----------------------------------------------------------------------------
|
|
58
|
+
GRANT USAGE ON SCHEMA public TO nextspark_app;
|
|
59
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO nextspark_app;
|
|
60
|
+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO nextspark_app;
|
|
61
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO nextspark_app;
|
|
62
|
+
|
|
63
|
+
-- Future objects created by the migration owner inherit the same grants.
|
|
64
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
|
65
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO nextspark_app;
|
|
66
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
|
67
|
+
GRANT USAGE, SELECT ON SEQUENCES TO nextspark_app;
|
|
68
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
|
69
|
+
GRANT EXECUTE ON FUNCTIONS TO nextspark_app;
|
|
70
|
+
|
|
71
|
+
-- ----------------------------------------------------------------------------
|
|
72
|
+
-- 3. anon: explicit lockdown (defense for PostgREST/Data API surfaces)
|
|
73
|
+
-- ----------------------------------------------------------------------------
|
|
74
|
+
DO $$
|
|
75
|
+
BEGIN
|
|
76
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
|
77
|
+
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon;
|
|
78
|
+
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM anon;
|
|
79
|
+
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM anon;
|
|
80
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM anon;
|
|
81
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon;
|
|
82
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon;
|
|
83
|
+
END IF;
|
|
84
|
+
END $$;
|
|
85
|
+
|
|
86
|
+
COMMENT ON ROLE nextspark_app IS
|
|
87
|
+
'Non-owner runtime role for the NextSpark app. Member of authenticated (policies TO authenticated apply). RLS is evaluated for every query once DATABASE_URL connects as this role instead of the table owner.';
|
package/dist/styles/classes.json
CHANGED
|
@@ -8,6 +8,7 @@ import { isPublicSignupRestricted } from "@nextsparkjs/core/lib/teams/helpers";
|
|
|
8
8
|
import { TeamService } from "@nextsparkjs/core/lib/services";
|
|
9
9
|
import { wrapAuthHandlerWithCors, handleCorsPreflightRequest, addCorsHeaders } from "@nextsparkjs/core/lib/api/helpers";
|
|
10
10
|
import { checkDistributedRateLimit } from "@nextsparkjs/core/lib/api/rate-limit";
|
|
11
|
+
import { withSignupContext } from "@nextsparkjs/core/lib/auth-context";
|
|
11
12
|
|
|
12
13
|
const handlers = toNextJsHandler(auth);
|
|
13
14
|
|
|
@@ -137,6 +138,18 @@ export async function POST(req: NextRequest) {
|
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
// Read the optional signup intent (`x-signup-intent` header) and run the signup
|
|
142
|
+
// within request-scoped context so the user.create.after hook can map it to an
|
|
143
|
+
// initial team role (AUTH_CONFIG.signupIntent).
|
|
144
|
+
const signupIntent = isSignupAttempt
|
|
145
|
+
? (req.headers.get('x-signup-intent') || undefined)
|
|
146
|
+
: undefined;
|
|
147
|
+
|
|
140
148
|
// Wrap with CORS headers for cross-origin requests (mobile apps, etc.)
|
|
141
|
-
return wrapAuthHandlerWithCors(
|
|
149
|
+
return wrapAuthHandlerWithCors(
|
|
150
|
+
signupIntent
|
|
151
|
+
? () => withSignupContext({ signupIntent }, () => handlers.POST(req))
|
|
152
|
+
: () => handlers.POST(req),
|
|
153
|
+
req
|
|
154
|
+
);
|
|
142
155
|
}
|
|
@@ -47,6 +47,76 @@ $$;
|
|
|
47
47
|
-- function with the alias, causing infinite recursion. All RLS policies should
|
|
48
48
|
-- use `public.get_auth_user_id()` with the explicit schema qualifier.
|
|
49
49
|
|
|
50
|
+
-- =============================================================================
|
|
51
|
+
-- RLS BYPASS / VISIBILITY PRIMITIVES
|
|
52
|
+
-- Defined here (migration 001) so EVERY later migration's policies can use them,
|
|
53
|
+
-- including the auth/identity tables in 002. These are plpgsql functions, so the
|
|
54
|
+
-- tables they reference (users, team_members) are resolved at CALL time, not at
|
|
55
|
+
-- function-creation time — it is safe to define them before those tables exist.
|
|
56
|
+
-- They are SECURITY DEFINER so they read team_members/users without re-triggering
|
|
57
|
+
-- RLS (avoids recursive policy evaluation).
|
|
58
|
+
-- =============================================================================
|
|
59
|
+
|
|
60
|
+
-- Can the current request user bypass RLS? (superadmin always; developer if a
|
|
61
|
+
-- member of the System Admin Team). Mirrors app-level bypass in dual-auth.ts.
|
|
62
|
+
CREATE OR REPLACE FUNCTION public.can_bypass_rls()
|
|
63
|
+
RETURNS BOOLEAN AS $$
|
|
64
|
+
DECLARE
|
|
65
|
+
current_user_id TEXT;
|
|
66
|
+
user_role TEXT;
|
|
67
|
+
is_system_admin_member BOOLEAN;
|
|
68
|
+
BEGIN
|
|
69
|
+
current_user_id := public.get_auth_user_id();
|
|
70
|
+
|
|
71
|
+
SELECT role INTO user_role
|
|
72
|
+
FROM public."users"
|
|
73
|
+
WHERE id = current_user_id;
|
|
74
|
+
|
|
75
|
+
IF user_role = 'superadmin' THEN
|
|
76
|
+
RETURN TRUE;
|
|
77
|
+
END IF;
|
|
78
|
+
|
|
79
|
+
IF user_role = 'developer' THEN
|
|
80
|
+
SELECT EXISTS(
|
|
81
|
+
SELECT 1 FROM public."team_members"
|
|
82
|
+
WHERE "userId" = current_user_id
|
|
83
|
+
AND "teamId" = 'team-nextspark-001'
|
|
84
|
+
) INTO is_system_admin_member;
|
|
85
|
+
|
|
86
|
+
RETURN is_system_admin_member;
|
|
87
|
+
END IF;
|
|
88
|
+
|
|
89
|
+
RETURN FALSE;
|
|
90
|
+
END;
|
|
91
|
+
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
92
|
+
|
|
93
|
+
-- Backward-compatible alias (deprecated, use can_bypass_rls).
|
|
94
|
+
CREATE OR REPLACE FUNCTION public.is_superadmin()
|
|
95
|
+
RETURNS BOOLEAN AS $$
|
|
96
|
+
BEGIN
|
|
97
|
+
RETURN public.can_bypass_rls();
|
|
98
|
+
END;
|
|
99
|
+
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
100
|
+
|
|
101
|
+
-- May the current request user SEE the row of `target_user_id`? True when they
|
|
102
|
+
-- share a team where either side is staff (owner/admin). Used by the `users`
|
|
103
|
+
-- SELECT policy (002) without a create-time dependency on team_members.
|
|
104
|
+
CREATE OR REPLACE FUNCTION public.auth_user_can_see_user(target_user_id TEXT)
|
|
105
|
+
RETURNS BOOLEAN AS $$
|
|
106
|
+
BEGIN
|
|
107
|
+
RETURN EXISTS (
|
|
108
|
+
SELECT 1 FROM public."team_members" me
|
|
109
|
+
JOIN public."team_members" them ON them."teamId" = me."teamId"
|
|
110
|
+
WHERE me."userId" = public.get_auth_user_id()
|
|
111
|
+
AND them."userId" = target_user_id
|
|
112
|
+
AND (
|
|
113
|
+
me.role IN ('owner','admin') -- I am staff of this team -> I see everyone in it
|
|
114
|
+
OR them.role IN ('owner','admin') -- the target is staff of my team -> I see them
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
END;
|
|
118
|
+
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
119
|
+
|
|
50
120
|
-- Utilidad: updatedAt (si no existe ya en otra migration)
|
|
51
121
|
CREATE OR REPLACE FUNCTION public.set_updated_at()
|
|
52
122
|
RETURNS trigger
|
|
@@ -96,16 +96,96 @@ ALTER TABLE "verification" ENABLE ROW LEVEL SECURITY;
|
|
|
96
96
|
-- POLICIES
|
|
97
97
|
-- ============================================
|
|
98
98
|
|
|
99
|
-
--
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
--
|
|
106
|
-
--
|
|
107
|
-
--
|
|
108
|
-
--
|
|
99
|
+
-- ============================================================================
|
|
100
|
+
-- Per-user RLS on the auth/identity tables (hardened defaults).
|
|
101
|
+
--
|
|
102
|
+
-- Uses ONLY core primitives: public.can_bypass_rls() (010) and
|
|
103
|
+
-- public.get_auth_user_id() (001), and public."team_members". "Staff of a team"
|
|
104
|
+
-- is the base elevated set the core enum knows: ('owner','admin'). A theme that
|
|
105
|
+
-- extends team roles widens the staff set through its own config-derived
|
|
106
|
+
-- mechanism; the core ships the base elevated set only.
|
|
107
|
+
--
|
|
108
|
+
-- WHY: under real RLS, the previous `USING(true)` let ANY authenticated user read
|
|
109
|
+
-- every row of users (PII), account (Better Auth credentials/providers) and
|
|
110
|
+
-- session (other users' sessions/tokens).
|
|
111
|
+
--
|
|
112
|
+
-- SERVICE DEPENDENCY (not a data hole): Better Auth reads these tables WITHOUT a
|
|
113
|
+
-- user GUC during login/verification, so it runs under the SERVICE connection
|
|
114
|
+
-- (DATABASE_SERVICE_URL, bypass). With it, login works AND these policies stay
|
|
115
|
+
-- active.
|
|
116
|
+
-- ============================================================================
|
|
117
|
+
|
|
118
|
+
-- ----------------------------------------------------------------------------
|
|
119
|
+
-- users — self + staff visibility (no user sees unrelated users)
|
|
120
|
+
-- ----------------------------------------------------------------------------
|
|
121
|
+
DROP POLICY IF EXISTS "users_allow_all" ON "users";
|
|
122
|
+
DROP POLICY IF EXISTS "Users self and staff read" ON "users";
|
|
123
|
+
DROP POLICY IF EXISTS "Users service insert" ON "users";
|
|
124
|
+
DROP POLICY IF EXISTS "Users self update" ON "users";
|
|
125
|
+
DROP POLICY IF EXISTS "Users service delete" ON "users";
|
|
126
|
+
|
|
127
|
+
-- Staff visibility is resolved by public.auth_user_can_see_user() (defined in
|
|
128
|
+
-- 001) so this policy has no create-time dependency on team_members (created in
|
|
129
|
+
-- a later migration).
|
|
130
|
+
CREATE POLICY "Users self and staff read"
|
|
131
|
+
ON "users"
|
|
132
|
+
FOR SELECT TO authenticated
|
|
133
|
+
USING (
|
|
134
|
+
public.can_bypass_rls()
|
|
135
|
+
OR id = public.get_auth_user_id()
|
|
136
|
+
OR public.auth_user_can_see_user(id)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
-- Identity is created/managed by the auth service; a user may edit ITS OWN row.
|
|
140
|
+
CREATE POLICY "Users service insert" ON "users"
|
|
141
|
+
FOR INSERT TO authenticated WITH CHECK (public.can_bypass_rls());
|
|
142
|
+
CREATE POLICY "Users self update" ON "users"
|
|
143
|
+
FOR UPDATE TO authenticated
|
|
144
|
+
USING (public.can_bypass_rls() OR id = public.get_auth_user_id())
|
|
145
|
+
WITH CHECK (public.can_bypass_rls() OR id = public.get_auth_user_id());
|
|
146
|
+
CREATE POLICY "Users service delete" ON "users"
|
|
147
|
+
FOR DELETE TO authenticated USING (public.can_bypass_rls());
|
|
148
|
+
|
|
149
|
+
-- ----------------------------------------------------------------------------
|
|
150
|
+
-- account — only the owner sees/uses its own auth accounts; writes are service
|
|
151
|
+
-- ----------------------------------------------------------------------------
|
|
152
|
+
DROP POLICY IF EXISTS "account_allow_all" ON "account";
|
|
153
|
+
DROP POLICY IF EXISTS "Account self read" ON "account";
|
|
154
|
+
DROP POLICY IF EXISTS "Account service write" ON "account";
|
|
155
|
+
|
|
156
|
+
CREATE POLICY "Account self read" ON "account"
|
|
157
|
+
FOR SELECT TO authenticated
|
|
158
|
+
USING (public.can_bypass_rls() OR "userId" = public.get_auth_user_id());
|
|
159
|
+
CREATE POLICY "Account service write" ON "account"
|
|
160
|
+
FOR ALL TO authenticated
|
|
161
|
+
USING (public.can_bypass_rls())
|
|
162
|
+
WITH CHECK (public.can_bypass_rls());
|
|
163
|
+
|
|
164
|
+
-- ----------------------------------------------------------------------------
|
|
165
|
+
-- session — only the owner sees its own sessions; writes are service (login)
|
|
166
|
+
-- ----------------------------------------------------------------------------
|
|
167
|
+
DROP POLICY IF EXISTS "session_allow_all" ON "session";
|
|
168
|
+
DROP POLICY IF EXISTS "Session self read" ON "session";
|
|
169
|
+
DROP POLICY IF EXISTS "Session service write" ON "session";
|
|
170
|
+
|
|
171
|
+
CREATE POLICY "Session self read" ON "session"
|
|
172
|
+
FOR SELECT TO authenticated
|
|
173
|
+
USING (public.can_bypass_rls() OR "userId" = public.get_auth_user_id());
|
|
174
|
+
CREATE POLICY "Session service write" ON "session"
|
|
175
|
+
FOR ALL TO authenticated
|
|
176
|
+
USING (public.can_bypass_rls())
|
|
177
|
+
WITH CHECK (public.can_bypass_rls());
|
|
178
|
+
|
|
179
|
+
-- ----------------------------------------------------------------------------
|
|
180
|
+
-- verification — email/reset tokens (no userId); service-only under RLS
|
|
181
|
+
-- ----------------------------------------------------------------------------
|
|
182
|
+
DROP POLICY IF EXISTS "verification_allow_all" ON "verification";
|
|
183
|
+
DROP POLICY IF EXISTS "Verification service all" ON "verification";
|
|
184
|
+
|
|
185
|
+
CREATE POLICY "Verification service all" ON "verification"
|
|
186
|
+
FOR ALL TO authenticated
|
|
187
|
+
USING (public.can_bypass_rls())
|
|
188
|
+
WITH CHECK (public.can_bypass_rls());
|
|
109
189
|
|
|
110
190
|
-- ============================================
|
|
111
191
|
-- CONSTRAINTS
|
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
-- ENUM TYPES (must be defined first)
|
|
8
8
|
-- ============================================
|
|
9
9
|
|
|
10
|
-
-- Team member roles
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
-- Team member roles are stored as TEXT (NOT a Postgres ENUM).
|
|
11
|
+
--
|
|
12
|
+
-- WHY: themes extend team roles via config (app.config.availableTeamRoles /
|
|
13
|
+
-- permissions.config.ts). A Postgres ENUM would force every theme to patch the
|
|
14
|
+
-- type with `ALTER TYPE team_role ADD VALUE ...`. Using TEXT lets a theme store
|
|
15
|
+
-- any role string without DB DDL. No privilege boundary is lost: RLS policies
|
|
16
|
+
-- compare against explicit literals ('owner','admin') and an unknown role fails
|
|
17
|
+
-- closed (matches no elevated tier). Value integrity is enforced at the app
|
|
18
|
+
-- layer (zod derived from availableTeamRoles + the permissions registry), so no
|
|
19
|
+
-- CHECK constraint is added (a CHECK with the base set would block theme roles).
|
|
14
20
|
|
|
15
21
|
-- Invitation status lifecycle
|
|
16
22
|
DO $$ BEGIN
|
|
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS public."team_members" (
|
|
|
10
10
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
11
11
|
"teamId" TEXT NOT NULL REFERENCES public."teams"(id) ON DELETE CASCADE,
|
|
12
12
|
"userId" TEXT NOT NULL REFERENCES public."users"(id) ON DELETE CASCADE,
|
|
13
|
-
role
|
|
13
|
+
role TEXT NOT NULL DEFAULT 'member', -- TEXT (not ENUM) so themes extend roles via config; see 007
|
|
14
14
|
"invitedBy" TEXT REFERENCES public."users"(id),
|
|
15
15
|
"joinedAt" TIMESTAMPTZ DEFAULT now(),
|
|
16
16
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS public."team_invitations" (
|
|
|
10
10
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
11
11
|
"teamId" TEXT NOT NULL REFERENCES public."teams"(id) ON DELETE CASCADE,
|
|
12
12
|
email TEXT NOT NULL,
|
|
13
|
-
role
|
|
13
|
+
role TEXT NOT NULL DEFAULT 'member', -- TEXT (not ENUM) so themes extend roles via config; see 007
|
|
14
14
|
status invitation_status NOT NULL DEFAULT 'pending',
|
|
15
15
|
token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text,
|
|
16
16
|
"invitedBy" TEXT NOT NULL REFERENCES public."users"(id),
|
|
@@ -13,7 +13,7 @@ RETURNS TABLE (
|
|
|
13
13
|
team_id TEXT,
|
|
14
14
|
team_name TEXT,
|
|
15
15
|
team_slug TEXT,
|
|
16
|
-
user_role
|
|
16
|
+
user_role TEXT, -- team roles are TEXT (not ENUM); see 007
|
|
17
17
|
joined_at TIMESTAMPTZ,
|
|
18
18
|
member_count BIGINT
|
|
19
19
|
) AS $$
|
|
@@ -39,11 +39,11 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
|
39
39
|
CREATE OR REPLACE FUNCTION has_team_permission(
|
|
40
40
|
user_id_param TEXT,
|
|
41
41
|
team_id_param TEXT,
|
|
42
|
-
required_roles
|
|
42
|
+
required_roles TEXT[] -- team roles are TEXT (not ENUM); see 007
|
|
43
43
|
)
|
|
44
44
|
RETURNS BOOLEAN AS $$
|
|
45
45
|
DECLARE
|
|
46
|
-
user_role
|
|
46
|
+
user_role TEXT;
|
|
47
47
|
BEGIN
|
|
48
48
|
SELECT role INTO user_role
|
|
49
49
|
FROM public."team_members"
|
|
@@ -108,51 +108,9 @@ BEGIN
|
|
|
108
108
|
END;
|
|
109
109
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
110
110
|
|
|
111
|
-
--
|
|
112
|
-
--
|
|
113
|
-
--
|
|
114
|
-
-- Matches app-level bypass logic in dual-auth.ts
|
|
115
|
-
CREATE OR REPLACE FUNCTION public.can_bypass_rls()
|
|
116
|
-
RETURNS BOOLEAN AS $$
|
|
117
|
-
DECLARE
|
|
118
|
-
current_user_id TEXT;
|
|
119
|
-
user_role TEXT;
|
|
120
|
-
is_system_admin_member BOOLEAN;
|
|
121
|
-
BEGIN
|
|
122
|
-
current_user_id := public.get_auth_user_id();
|
|
123
|
-
|
|
124
|
-
-- Get user role
|
|
125
|
-
SELECT role INTO user_role
|
|
126
|
-
FROM public."users"
|
|
127
|
-
WHERE id = current_user_id;
|
|
128
|
-
|
|
129
|
-
-- Superadmin always bypasses
|
|
130
|
-
IF user_role = 'superadmin' THEN
|
|
131
|
-
RETURN TRUE;
|
|
132
|
-
END IF;
|
|
133
|
-
|
|
134
|
-
-- Developer can bypass if member of System Admin Team (team-nextspark-001)
|
|
135
|
-
IF user_role = 'developer' THEN
|
|
136
|
-
SELECT EXISTS(
|
|
137
|
-
SELECT 1 FROM public."team_members"
|
|
138
|
-
WHERE "userId" = current_user_id
|
|
139
|
-
AND "teamId" = 'team-nextspark-001'
|
|
140
|
-
) INTO is_system_admin_member;
|
|
141
|
-
|
|
142
|
-
RETURN is_system_admin_member;
|
|
143
|
-
END IF;
|
|
144
|
-
|
|
145
|
-
RETURN FALSE;
|
|
146
|
-
END;
|
|
147
|
-
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
148
|
-
|
|
149
|
-
-- Alias for backward compatibility (deprecated, use can_bypass_rls)
|
|
150
|
-
CREATE OR REPLACE FUNCTION public.is_superadmin()
|
|
151
|
-
RETURNS BOOLEAN AS $$
|
|
152
|
-
BEGIN
|
|
153
|
-
RETURN public.can_bypass_rls();
|
|
154
|
-
END;
|
|
155
|
-
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
111
|
+
-- NOTE: public.can_bypass_rls() and public.is_superadmin() are defined in
|
|
112
|
+
-- migration 001 (foundational RLS primitives) so that the auth/identity table
|
|
113
|
+
-- policies in 002 can use them. They are intentionally NOT redefined here.
|
|
156
114
|
|
|
157
115
|
-- ============================================
|
|
158
116
|
-- TEAMS RLS POLICIES
|
|
@@ -90,59 +90,89 @@ WHERE status IN ('active', 'trialing', 'past_due');
|
|
|
90
90
|
-- ============================================
|
|
91
91
|
ALTER TABLE public."subscriptions" ENABLE ROW LEVEL SECURITY;
|
|
92
92
|
|
|
93
|
-
--
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
-- ============================================================================
|
|
94
|
+
-- Per-user RLS on subscriptions (hardened).
|
|
95
|
+
--
|
|
96
|
+
-- WHY: a team's "team read" let ANY team member read the team's subscriptions.
|
|
97
|
+
-- When subscriptions are per (userId, teamId) and a team holds many users,
|
|
98
|
+
-- team-wide read means every user reads every other user's membership row.
|
|
99
|
+
-- Uses ONLY core primitives: can_bypass_rls() (superadmin/developer/service
|
|
100
|
+
-- bypass), get_auth_user_id(), team_members. Elevated tier = ('owner','admin').
|
|
101
|
+
--
|
|
102
|
+
-- NOTE on subscriptions."userId": NULLable by design (team-level subscriptions).
|
|
103
|
+
-- The own-row branch evaluates NULL -> false, so team-level subscriptions are
|
|
104
|
+
-- visible only to the elevated tier. Correct and intended.
|
|
105
|
+
-- ============================================================================
|
|
97
106
|
|
|
98
|
-
--
|
|
99
|
-
|
|
107
|
+
-- Cleanup existing policies
|
|
108
|
+
DROP POLICY IF EXISTS "Subscriptions team read" ON public."subscriptions";
|
|
109
|
+
DROP POLICY IF EXISTS "Subscriptions team write" ON public."subscriptions";
|
|
110
|
+
DROP POLICY IF EXISTS "Subscriptions superadmin" ON public."subscriptions";
|
|
111
|
+
DROP POLICY IF EXISTS "Subscriptions per-user select" ON public."subscriptions";
|
|
112
|
+
DROP POLICY IF EXISTS "Subscriptions elevated insert" ON public."subscriptions";
|
|
113
|
+
DROP POLICY IF EXISTS "Subscriptions elevated update" ON public."subscriptions";
|
|
114
|
+
DROP POLICY IF EXISTS "Subscriptions elevated delete" ON public."subscriptions";
|
|
115
|
+
|
|
116
|
+
CREATE POLICY "Subscriptions per-user select"
|
|
100
117
|
ON public."subscriptions"
|
|
101
118
|
FOR SELECT TO authenticated
|
|
102
119
|
USING (
|
|
103
|
-
|
|
120
|
+
public.can_bypass_rls()
|
|
121
|
+
OR "userId" = public.get_auth_user_id()
|
|
122
|
+
OR EXISTS (
|
|
123
|
+
SELECT 1 FROM public."team_members" tm
|
|
124
|
+
WHERE tm."teamId" = public."subscriptions"."teamId"
|
|
125
|
+
AND tm."userId" = public.get_auth_user_id()
|
|
126
|
+
AND tm.role IN ('owner','admin')
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
-- Writes: elevated tier or bypass only — NEVER an own-row branch (a user must
|
|
131
|
+
-- not be able to grant themselves a membership).
|
|
132
|
+
CREATE POLICY "Subscriptions elevated insert"
|
|
133
|
+
ON public."subscriptions"
|
|
134
|
+
FOR INSERT TO authenticated
|
|
135
|
+
WITH CHECK (
|
|
136
|
+
public.can_bypass_rls()
|
|
137
|
+
OR EXISTS (
|
|
104
138
|
SELECT 1 FROM public."team_members" tm
|
|
105
|
-
WHERE tm."teamId" = "subscriptions"."teamId"
|
|
139
|
+
WHERE tm."teamId" = public."subscriptions"."teamId"
|
|
106
140
|
AND tm."userId" = public.get_auth_user_id()
|
|
141
|
+
AND tm.role IN ('owner','admin')
|
|
107
142
|
)
|
|
108
143
|
);
|
|
109
144
|
|
|
110
|
-
|
|
111
|
-
CREATE POLICY "Subscriptions team write"
|
|
145
|
+
CREATE POLICY "Subscriptions elevated update"
|
|
112
146
|
ON public."subscriptions"
|
|
113
|
-
FOR
|
|
147
|
+
FOR UPDATE TO authenticated
|
|
114
148
|
USING (
|
|
115
|
-
|
|
149
|
+
public.can_bypass_rls()
|
|
150
|
+
OR EXISTS (
|
|
116
151
|
SELECT 1 FROM public."team_members" tm
|
|
117
|
-
WHERE tm."teamId" = "subscriptions"."teamId"
|
|
152
|
+
WHERE tm."teamId" = public."subscriptions"."teamId"
|
|
118
153
|
AND tm."userId" = public.get_auth_user_id()
|
|
119
|
-
AND tm.role IN ('owner',
|
|
154
|
+
AND tm.role IN ('owner','admin')
|
|
120
155
|
)
|
|
121
156
|
)
|
|
122
157
|
WITH CHECK (
|
|
123
|
-
|
|
158
|
+
public.can_bypass_rls()
|
|
159
|
+
OR EXISTS (
|
|
124
160
|
SELECT 1 FROM public."team_members" tm
|
|
125
|
-
WHERE tm."teamId" = "subscriptions"."teamId"
|
|
161
|
+
WHERE tm."teamId" = public."subscriptions"."teamId"
|
|
126
162
|
AND tm."userId" = public.get_auth_user_id()
|
|
127
|
-
AND tm.role IN ('owner',
|
|
163
|
+
AND tm.role IN ('owner','admin')
|
|
128
164
|
)
|
|
129
165
|
);
|
|
130
166
|
|
|
131
|
-
|
|
132
|
-
CREATE POLICY "Subscriptions superadmin"
|
|
167
|
+
CREATE POLICY "Subscriptions elevated delete"
|
|
133
168
|
ON public."subscriptions"
|
|
134
|
-
FOR
|
|
169
|
+
FOR DELETE TO authenticated
|
|
135
170
|
USING (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
142
|
-
WITH CHECK (
|
|
143
|
-
EXISTS (
|
|
144
|
-
SELECT 1 FROM public."users" u
|
|
145
|
-
WHERE u.id = public.get_auth_user_id()
|
|
146
|
-
AND u.role = 'superadmin'
|
|
171
|
+
public.can_bypass_rls()
|
|
172
|
+
OR EXISTS (
|
|
173
|
+
SELECT 1 FROM public."team_members" tm
|
|
174
|
+
WHERE tm."teamId" = public."subscriptions"."teamId"
|
|
175
|
+
AND tm."userId" = public.get_auth_user_id()
|
|
176
|
+
AND tm.role IN ('owner','admin')
|
|
147
177
|
)
|
|
148
178
|
);
|
|
@@ -80,8 +80,22 @@ USING (
|
|
|
80
80
|
)
|
|
81
81
|
);
|
|
82
82
|
|
|
83
|
-
--
|
|
84
|
-
|
|
83
|
+
-- INSERT: bypass or team owner/admin only. Payment-provider webhooks insert via
|
|
84
|
+
-- the SERVICE connection (DATABASE_SERVICE_URL, bypass). The previous
|
|
85
|
+
-- WITH CHECK (true) let ANY authenticated user forge billing events.
|
|
86
|
+
DROP POLICY IF EXISTS "Billing events elevated insert" ON public."billing_events";
|
|
87
|
+
CREATE POLICY "Billing events elevated insert"
|
|
85
88
|
ON public."billing_events"
|
|
86
89
|
FOR INSERT TO authenticated
|
|
87
|
-
WITH CHECK (
|
|
90
|
+
WITH CHECK (
|
|
91
|
+
public.can_bypass_rls()
|
|
92
|
+
OR "subscriptionId" IN (
|
|
93
|
+
SELECT s.id FROM public."subscriptions" s
|
|
94
|
+
WHERE EXISTS (
|
|
95
|
+
SELECT 1 FROM public."team_members" tm
|
|
96
|
+
WHERE tm."teamId" = s."teamId"
|
|
97
|
+
AND tm."userId" = public.get_auth_user_id()
|
|
98
|
+
AND tm.role IN ('owner','admin')
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
);
|
|
@@ -116,12 +116,14 @@ ALTER TABLE public."scheduled_actions" ENABLE ROW LEVEL SECURITY;
|
|
|
116
116
|
-- Cleanup existing policies
|
|
117
117
|
DROP POLICY IF EXISTS "scheduled_actions auth can select" ON public."scheduled_actions";
|
|
118
118
|
DROP POLICY IF EXISTS "scheduled_actions system can do all" ON public."scheduled_actions";
|
|
119
|
+
DROP POLICY IF EXISTS "Scheduled actions service all" ON public."scheduled_actions";
|
|
119
120
|
|
|
120
|
-
--
|
|
121
|
-
|
|
121
|
+
-- Service-only under real RLS. Payloads may carry provisioning PII, so the
|
|
122
|
+
-- previous `USING(true)` SELECT is removed. Reads go to bypass (DevTools runs as
|
|
123
|
+
-- the developer role -> can_bypass_rls()); the in-request scheduler and the
|
|
124
|
+
-- processor INSERT/UPDATE under the SERVICE connection (DATABASE_SERVICE_URL).
|
|
125
|
+
CREATE POLICY "Scheduled actions service all"
|
|
122
126
|
ON public."scheduled_actions"
|
|
123
|
-
FOR
|
|
124
|
-
USING (
|
|
125
|
-
|
|
126
|
-
-- No INSERT/UPDATE/DELETE policies for regular users
|
|
127
|
-
-- System operations are handled via service role or direct API with CRON_SECRET
|
|
127
|
+
FOR ALL TO authenticated
|
|
128
|
+
USING (public.can_bypass_rls())
|
|
129
|
+
WITH CHECK (public.can_bypass_rls());
|