@nextblock-cms/db 0.22.2 → 0.22.3
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/lib/media-actions.cjs.js +1 -1
- package/lib/media-actions.d.ts +13 -35
- package/lib/media-actions.es.js +25 -49
- package/lib/media-actions.test.d.ts +1 -0
- package/lib/media-record.cjs.js +1 -0
- package/lib/media-record.d.ts +46 -0
- package/lib/media-record.es.js +47 -0
- package/lib/package-validation.cjs.js +1 -1
- package/lib/package-validation.es.js +1 -1
- package/lib/secret-crypto.cjs.js +1 -1
- package/lib/secret-crypto.es.js +1 -0
- package/lib/supabase/server.cjs.js +1 -1
- package/lib/supabase/server.es.js +1 -0
- package/package.json +1 -1
- package/server.cjs.js +1 -1
- package/server.d.ts +1 -0
- package/server.es.js +6 -5
- package/supabase/migrations/02025_api_role_grant_hardening.sql +66 -0
- package/supabase/migrations/02026_authenticated_write_grant_hardening.sql +91 -0
- package/supabase/migrations/02027_api_role_select_grant_hardening.sql +150 -0
- package/supabase/migrations/02028_writer_settings_allowlist_email_factor_aal_and_media_keys.sql +284 -0
package/lib/media-actions.cjs.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use server";const e=require("./supabase/server.cjs.js")
|
|
1
|
+
"use server";const e=require("./supabase/server.cjs.js"),t=require("./media-record.cjs.js");require("server-only");let n=require("@nextblock-cms/utils/server");var r=Symbol.for(`nextblock.cms.secondFactorGate`);async function i(e,t){let n=globalThis[r];if(typeof n==`function`)return!!await n();let{data:i}=await e.from(`user_security_settings`).select(`mfa_enabled, mfa_type`).eq(`user_id`,t).maybeSingle();if(!i?.mfa_enabled||!i?.mfa_type)return!0;if(i.mfa_type===`totp`){let{data:t}=await e.auth.mfa.getAuthenticatorAssuranceLevel();return t?.currentLevel===`aal2`}return!1}async function a(r,a){let o=e.createClient(),{data:{user:s},error:c}=await o.auth.getUser();if(c||!s)return a?{error:`User not authenticated for media record.`}:(0,n.encodedRedirect)(`error`,`/cms/media`,`User not authenticated for media record.`);let{data:l}=await o.from(`profiles`).select(`role`).eq(`id`,s.id).single();if(!l||![`ADMIN`,`WRITER`].includes(l.role)||!await i(o,s.id))return a?{error:`Forbidden: Insufficient permissions to record media.`}:(0,n.encodedRedirect)(`error`,`/cms/media`,`Forbidden: Insufficient permissions to record media.`);let u=await t.insertMediaRecord(o,s.id,r);if(`error`in u)return a?u:(0,n.encodedRedirect)(`error`,`/cms/media`,u.error);if(a)return u;(0,n.encodedRedirect)(`success`,`/cms/media`,`Media recorded successfully.`)}exports.recordMediaUpload=a;
|
package/lib/media-actions.d.ts
CHANGED
|
@@ -1,35 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
description?: string;
|
|
15
|
-
r2OriginalKey: string;
|
|
16
|
-
r2Variants: ImageVariant[];
|
|
17
|
-
originalImageDetails: ImageVariant;
|
|
18
|
-
blurDataUrl?: string;
|
|
19
|
-
/**
|
|
20
|
-
* Uploader for callers that have no cookie session — the MCP server, which
|
|
21
|
-
* authenticates by bearer token instead. When set, the row is written with the
|
|
22
|
-
* service-role client and attributed to this user rather than to `auth.getUser()`.
|
|
23
|
-
*
|
|
24
|
-
* This grants no new authority: the role check below still runs against the
|
|
25
|
-
* supplied id, so a non-ADMIN/WRITER actor is rejected exactly as a cookie
|
|
26
|
-
* session would be. The caller is responsible for having authenticated them.
|
|
27
|
-
*/
|
|
28
|
-
actorUserId?: string;
|
|
29
|
-
}, returnJustData?: boolean): Promise<{
|
|
30
|
-
success: true;
|
|
31
|
-
data: Media;
|
|
32
|
-
} | {
|
|
33
|
-
error: string;
|
|
34
|
-
} | void>;
|
|
35
|
-
export {};
|
|
1
|
+
import { MediaRecordResult, MediaUploadPayload } from './media-record';
|
|
2
|
+
export type { ImageVariant, MediaRecordResult, MediaUploadPayload } from './media-record';
|
|
3
|
+
/**
|
|
4
|
+
* Record an upload as the signed-in ADMIN or WRITER.
|
|
5
|
+
*
|
|
6
|
+
* A server action is a public POST endpoint, so the uploader is always the cookie
|
|
7
|
+
* session's user, never anything in the payload. Until 2026-10-21 the payload could name
|
|
8
|
+
* one (`actorUserId`, for the MCP server) and the session cross-check ran only when a
|
|
9
|
+
* session existed, so a cookieless POST naming an admin's id recorded media as that admin
|
|
10
|
+
* with the service role. Server routes without a session use `recordMediaUploadAsActor`
|
|
11
|
+
* (`@nextblock-cms/db/server`), which is not a server action.
|
|
12
|
+
*/
|
|
13
|
+
export declare function recordMediaUpload(payload: MediaUploadPayload, returnJustData?: boolean): Promise<MediaRecordResult | void>;
|
package/lib/media-actions.es.js
CHANGED
|
@@ -1,54 +1,30 @@
|
|
|
1
1
|
"use server";
|
|
2
|
-
import { createClient as e
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
2
|
+
import { createClient as e } from "./supabase/server.es.js";
|
|
3
|
+
import { insertMediaRecord as t } from "./media-record.es.js";
|
|
4
|
+
import "server-only";
|
|
5
|
+
import { encodedRedirect as n } from "@nextblock-cms/utils/server";
|
|
5
6
|
//#region src/lib/media-actions.ts
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
var r = Symbol.for("nextblock.cms.secondFactorGate");
|
|
8
|
+
async function i(e, t) {
|
|
9
|
+
let n = globalThis[r];
|
|
10
|
+
if (typeof n == "function") return !!await n();
|
|
11
|
+
let { data: i } = await e.from("user_security_settings").select("mfa_enabled, mfa_type").eq("user_id", t).maybeSingle();
|
|
12
|
+
if (!i?.mfa_enabled || !i?.mfa_type) return !0;
|
|
13
|
+
if (i.mfa_type === "totp") {
|
|
14
|
+
let { data: t } = await e.auth.mfa.getAuthenticatorAssuranceLevel();
|
|
15
|
+
return t?.currentLevel === "aal2";
|
|
13
16
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
26
|
-
let d = [...i.originalImageDetails && i.originalImageDetails.objectKey !== u.objectKey ? [i.originalImageDetails] : [], ...i.r2Variants].filter((e, t, n) => t === n.findIndex((t) => t.objectKey === e.objectKey)), f = i.description ?? (u.fileType?.startsWith("image/") || i.originalImageDetails?.fileType?.startsWith("image/") ? ((e) => {
|
|
27
|
-
let t = e.lastIndexOf(".");
|
|
28
|
-
return (t > 0 ? e.substring(0, t) : e).replace(/[-+_\\]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w+/g, (e) => e.charAt(0).toUpperCase() + e.slice(1));
|
|
29
|
-
})(i.fileName) : null), p = {
|
|
30
|
-
uploader_id: c,
|
|
31
|
-
file_name: i.fileName,
|
|
32
|
-
object_key: u.objectKey,
|
|
33
|
-
file_path: u.objectKey,
|
|
34
|
-
folder: (() => {
|
|
35
|
-
let e = u.objectKey.match(/^(.*\/)?.*$/);
|
|
36
|
-
return e && e[1] ? e[1] : null;
|
|
37
|
-
})(),
|
|
38
|
-
file_type: u.fileType,
|
|
39
|
-
size_bytes: u.sizeBytes,
|
|
40
|
-
description: f,
|
|
41
|
-
width: u.width,
|
|
42
|
-
height: u.height,
|
|
43
|
-
variants: d,
|
|
44
|
-
blur_data_url: i.blurDataUrl || null
|
|
45
|
-
}, { data: m, error: h } = await s.from("media").insert(p).select().single();
|
|
46
|
-
if (h) return console.error("Error recording media upload:", h), a ? { error: `Failed to record media: ${h.message}` } : r("error", "/cms/media", `Failed to record media: ${h.message}`);
|
|
47
|
-
if (n("/cms/media"), a) return {
|
|
48
|
-
success: !0,
|
|
49
|
-
data: m
|
|
50
|
-
};
|
|
51
|
-
r("success", "/cms/media", "Media recorded successfully.");
|
|
17
|
+
return !1;
|
|
18
|
+
}
|
|
19
|
+
async function a(r, a) {
|
|
20
|
+
let o = e(), { data: { user: s }, error: c } = await o.auth.getUser();
|
|
21
|
+
if (c || !s) return a ? { error: "User not authenticated for media record." } : n("error", "/cms/media", "User not authenticated for media record.");
|
|
22
|
+
let { data: l } = await o.from("profiles").select("role").eq("id", s.id).single();
|
|
23
|
+
if (!l || !["ADMIN", "WRITER"].includes(l.role) || !await i(o, s.id)) return a ? { error: "Forbidden: Insufficient permissions to record media." } : n("error", "/cms/media", "Forbidden: Insufficient permissions to record media.");
|
|
24
|
+
let u = await t(o, s.id, r);
|
|
25
|
+
if ("error" in u) return a ? u : n("error", "/cms/media", u.error);
|
|
26
|
+
if (a) return u;
|
|
27
|
+
n("success", "/cms/media", "Media recorded successfully.");
|
|
52
28
|
}
|
|
53
29
|
//#endregion
|
|
54
|
-
export {
|
|
30
|
+
export { a as recordMediaUpload };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=require("./supabase/server.cjs.js");require("server-only");let t=require("next/cache");var n=[`ADMIN`,`WRITER`];async function r(e,n,r){let i=r.r2Variants.find(e=>e.variantLabel===`original_avif`)||r.r2Variants.find(e=>e.variantLabel===`xlarge_avif`)||r.r2Variants[0]||r.originalImageDetails;i||=r.originalImageDetails||{objectKey:r.r2OriginalKey,url:``,width:0,height:0,fileType:`application/octet-stream`,sizeBytes:0,variantLabel:`fallback_original`};let a=[...r.originalImageDetails&&r.originalImageDetails.objectKey!==i.objectKey?[r.originalImageDetails]:[],...r.r2Variants].filter((e,t,n)=>t===n.findIndex(t=>t.objectKey===e.objectKey)),o=r.description??(i.fileType?.startsWith(`image/`)||r.originalImageDetails?.fileType?.startsWith(`image/`)?(e=>{let t=e.lastIndexOf(`.`);return(t>0?e.substring(0,t):e).replace(/[-+_\\]+/g,` `).replace(/\s+/g,` `).trim().replace(/\b\w+/g,e=>e.charAt(0).toUpperCase()+e.slice(1))})(r.fileName):null),s={uploader_id:n,file_name:r.fileName,object_key:i.objectKey,file_path:i.objectKey,folder:(()=>{let e=i.objectKey.match(/^(.*\/)?.*$/);return e&&e[1]?e[1]:null})(),file_type:i.fileType,size_bytes:i.sizeBytes,description:o,width:i.width,height:i.height,variants:a,blur_data_url:r.blurDataUrl||null},{data:c,error:l}=await e.from(`media`).insert(s).select().single();return l?(console.error(`Error recording media upload:`,l),{error:`Failed to record media: ${l.message}`}):((0,t.revalidatePath)(`/cms/media`),{success:!0,data:c})}async function i(t,i){let a=e.getServiceRoleSupabaseClient(),{data:o}=await a.from(`profiles`).select(`role`).eq(`id`,i).maybeSingle();return!o||!n.includes(o.role)?{error:`Forbidden: Insufficient permissions to record media.`}:r(a,i,t)}exports.insertMediaRecord=r,exports.recordMediaUploadAsActor=i;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Database } from './supabase/types';
|
|
2
|
+
type Media = Database['public']['Tables']['media']['Row'];
|
|
3
|
+
export interface ImageVariant {
|
|
4
|
+
objectKey: string;
|
|
5
|
+
url: string;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
fileType: string;
|
|
9
|
+
sizeBytes: number;
|
|
10
|
+
variantLabel: string;
|
|
11
|
+
}
|
|
12
|
+
export type MediaUploadPayload = {
|
|
13
|
+
fileName: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
r2OriginalKey: string;
|
|
16
|
+
r2Variants: ImageVariant[];
|
|
17
|
+
originalImageDetails: ImageVariant;
|
|
18
|
+
blurDataUrl?: string;
|
|
19
|
+
};
|
|
20
|
+
export type MediaRecordResult = {
|
|
21
|
+
success: true;
|
|
22
|
+
data: Media;
|
|
23
|
+
} | {
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
26
|
+
type MediaInsertClient = {
|
|
27
|
+
from: (table: string) => any;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Insert the `media` row for an upload, attributed to `uploaderId`. Takes the payload's
|
|
31
|
+
* known fields only. The caller has already established who `uploaderId` is: the cookie
|
|
32
|
+
* session (`recordMediaUpload`) or an authenticated server route (`recordMediaUploadAsActor`).
|
|
33
|
+
*/
|
|
34
|
+
export declare function insertMediaRecord(supabase: MediaInsertClient, uploaderId: string, payload: MediaUploadPayload): Promise<MediaRecordResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Record an upload for a server route that has no cookie session and has already
|
|
37
|
+
* authenticated its caller: `/api/mcp` (bearer token) and `/api/ai/*`. Writes with the
|
|
38
|
+
* service role, after re-reading the actor's role.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately NOT a server action (no `'use server'`, and not exported from the
|
|
41
|
+
* client-safe `@nextblock-cms/db` entry): until 2026-10-21 `recordMediaUpload` accepted
|
|
42
|
+
* this actor id from its payload, so a cookieless POST naming an admin's id (public via
|
|
43
|
+
* `posts.author_id`) recorded media as that admin.
|
|
44
|
+
*/
|
|
45
|
+
export declare function recordMediaUploadAsActor(payload: MediaUploadPayload, actorUserId: string): Promise<MediaRecordResult>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getServiceRoleSupabaseClient as e } from "./supabase/server.es.js";
|
|
2
|
+
import "server-only";
|
|
3
|
+
import { revalidatePath as t } from "next/cache";
|
|
4
|
+
//#region src/lib/media-record.ts
|
|
5
|
+
var n = ["ADMIN", "WRITER"];
|
|
6
|
+
async function r(e, n, r) {
|
|
7
|
+
let i = r.r2Variants.find((e) => e.variantLabel === "original_avif") || r.r2Variants.find((e) => e.variantLabel === "xlarge_avif") || r.r2Variants[0] || r.originalImageDetails;
|
|
8
|
+
i ||= r.originalImageDetails || {
|
|
9
|
+
objectKey: r.r2OriginalKey,
|
|
10
|
+
url: "",
|
|
11
|
+
width: 0,
|
|
12
|
+
height: 0,
|
|
13
|
+
fileType: "application/octet-stream",
|
|
14
|
+
sizeBytes: 0,
|
|
15
|
+
variantLabel: "fallback_original"
|
|
16
|
+
};
|
|
17
|
+
let a = [...r.originalImageDetails && r.originalImageDetails.objectKey !== i.objectKey ? [r.originalImageDetails] : [], ...r.r2Variants].filter((e, t, n) => t === n.findIndex((t) => t.objectKey === e.objectKey)), o = r.description ?? (i.fileType?.startsWith("image/") || r.originalImageDetails?.fileType?.startsWith("image/") ? ((e) => {
|
|
18
|
+
let t = e.lastIndexOf(".");
|
|
19
|
+
return (t > 0 ? e.substring(0, t) : e).replace(/[-+_\\]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w+/g, (e) => e.charAt(0).toUpperCase() + e.slice(1));
|
|
20
|
+
})(r.fileName) : null), s = {
|
|
21
|
+
uploader_id: n,
|
|
22
|
+
file_name: r.fileName,
|
|
23
|
+
object_key: i.objectKey,
|
|
24
|
+
file_path: i.objectKey,
|
|
25
|
+
folder: (() => {
|
|
26
|
+
let e = i.objectKey.match(/^(.*\/)?.*$/);
|
|
27
|
+
return e && e[1] ? e[1] : null;
|
|
28
|
+
})(),
|
|
29
|
+
file_type: i.fileType,
|
|
30
|
+
size_bytes: i.sizeBytes,
|
|
31
|
+
description: o,
|
|
32
|
+
width: i.width,
|
|
33
|
+
height: i.height,
|
|
34
|
+
variants: a,
|
|
35
|
+
blur_data_url: r.blurDataUrl || null
|
|
36
|
+
}, { data: c, error: l } = await e.from("media").insert(s).select().single();
|
|
37
|
+
return l ? (console.error("Error recording media upload:", l), { error: `Failed to record media: ${l.message}` }) : (t("/cms/media"), {
|
|
38
|
+
success: !0,
|
|
39
|
+
data: c
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function i(t, i) {
|
|
43
|
+
let a = e(), { data: o } = await a.from("profiles").select("role").eq("id", i).maybeSingle();
|
|
44
|
+
return !o || !n.includes(o.role) ? { error: "Forbidden: Insufficient permissions to record media." } : r(a, i, t);
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { r as insertMediaRecord, i as recordMediaUploadAsActor };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const e=require("./supabase/server.cjs.js");let t=require("@supabase/supabase-js"),n=require("next/cache");
|
|
1
|
+
const e=require("./supabase/server.cjs.js");require("server-only");let t=require("@supabase/supabase-js"),n=require("next/cache");var r=`package-activation`;function i(e){if(typeof e!=`string`||!e.trim())return null;let t=e.trim(),n=/(?:z|[+-]\d{2}:?\d{2})$/i.test(t),r=Date.parse(n?t:`${t.replace(` `,`T`)}Z`);return Number.isFinite(r)?r:null}function a(e){if(!e||typeof e!=`object`||Array.isArray(e))return null;let t=e,n=t.nextblock&&typeof t.nextblock==`object`&&!Array.isArray(t.nextblock)?t.nextblock:null,r=[n?.expiration,n?.trial_ends_at,t.expiration].map(i).filter(e=>e!==null);return r.length>0?Math.min(...r):null}function o(e,t=Date.now()){if(e.status!==`active`)return!1;let n=a(e.meta);return n===null||n>t}async function s(e,t){try{let{data:n,error:r}=await t.from(`package_activations`).select(`status, meta`).eq(`package_id`,e).eq(`status`,`active`).limit(5);return r||!Array.isArray(n)?!1:n.some(e=>o(e))}catch(t){return console.error(`Error verifying package ${e}:`,t),!1}}var c=(0,n.unstable_cache)(async e=>{let n=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,r=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY||process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY;return!n||!r?!1:s(e,(0,t.createClient)(n,r,{auth:{persistSession:!1,autoRefreshToken:!1,detectSessionInUrl:!1}}))},[`package-activation`],{revalidate:60,tags:[r]});async function l(t,n){if(n)return s(t,n);let r=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,i=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY||process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY;return r&&i?c(t):s(t,await e.createClient())}exports.PACKAGE_ACTIVATION_CACHE_TAG=r,exports.isPackageActivationRowValid=o,exports.resolvePackageActivationExpiry=a,exports.verifyPackageOnline=l;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createClient as e } from "./supabase/server.es.js";
|
|
2
|
+
import "server-only";
|
|
2
3
|
import { createClient as t } from "@supabase/supabase-js";
|
|
3
4
|
import { unstable_cache as n } from "next/cache";
|
|
4
|
-
import "server-only";
|
|
5
5
|
//#region src/lib/package-validation.ts
|
|
6
6
|
var r = "package-activation";
|
|
7
7
|
function i(e) {
|
package/lib/secret-crypto.cjs.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
let e=require("node:crypto");var t=`aes-256-gcm`,n=1;function r(t){let n=t.trim();if(!n)throw Error(`A non-empty encryption secret is required to manage stored secrets.`);return(0,e.createHash)(`sha256`).update(n).digest()}function i(e){if(!e||typeof e!=`object`)return!1;let t=e;return t.algorithm===`aes-256-gcm`&&t.version===1&&typeof t.authTag==`string`&&typeof t.ciphertext==`string`&&typeof t.iv==`string`}function a(e){if(!i(e))throw Error(`Invalid encrypted secret payload.`);let t=e;return{algorithm:t.algorithm,authTag:t.authTag,ciphertext:t.ciphertext,iv:t.iv,last4:typeof t.last4==`string`?t.last4:``,updatedAt:typeof t.updatedAt==`string`?t.updatedAt:``,version:t.version}}function o(n){let i=n.value.trim();if(!i)throw Error(`A non-empty value is required to encrypt a secret.`);let a=r(n.encryptionSecret),o=(0,e.randomBytes)(12),s=(0,e.createCipheriv)(t,a,o),c=Buffer.concat([s.update(i,`utf8`),s.final()]);return{algorithm:t,authTag:s.getAuthTag().toString(`base64`),ciphertext:c.toString(`base64`),iv:o.toString(`base64`),last4:i.slice(-4),updatedAt:(n.now??new Date).toISOString(),version:1}}function s(n){let i=a(n.envelope),o=r(n.encryptionSecret);try{let n=(0,e.createDecipheriv)(t,o,Buffer.from(i.iv,`base64`));return n.setAuthTag(Buffer.from(i.authTag,`base64`)),Buffer.concat([n.update(Buffer.from(i.ciphertext,`base64`)),n.final()]).toString(`utf8`)}catch{throw Error(`Failed to decrypt stored secret.`)}}function c(e){let t=(e??``).trim();return t?`•••• ${t}`:`Stored secret`}function l(e){if(!i(e))return{hasStoredValue:!1,last4:null,maskedValue:null,updatedAt:null};let t=e,n=typeof t.last4==`string`&&t.last4?t.last4:null;return{hasStoredValue:!0,last4:n,maskedValue:c(n),updatedAt:typeof t.updatedAt==`string`&&t.updatedAt?t.updatedAt:null}}var u=`nextblock-secret-encryption-key-v1`;function d(){let t=process.env.SUPABASE_SERVICE_ROLE_KEY??process.env.SUPABASE_SECRET_KEY;return typeof t!=`string`||!t.trim()?null:(0,e.createHmac)(`sha256`,t.trim()).update(u).digest(`hex`)}function f(){let e=[],t=t=>{let n=typeof t==`string`?t.trim():``;n&&!e.includes(n)&&e.push(n)};return t(process.env.NEXTBLOCK_ENCRYPTION_KEY),t(process.env.CORTEX_AI_ENCRYPTION_KEY),t(d()),e}function p(){return f()[0]??null}function m(){return p()!==null}function h(){let e=p();if(!e)throw Error(`An encryption key (NEXTBLOCK_ENCRYPTION_KEY or CORTEX_AI_ENCRYPTION_KEY) is required to manage stored secrets.`);return e}function g(e,t){return o({value:e,encryptionSecret:h(),now:t})}function _(e){return s({envelope:e,encryptionSecret:h()})}function v(e){if(!i(e))return null;for(let t of f())try{return s({envelope:e,encryptionSecret:t})}catch{}return null}exports.SECRET_ENVELOPE_ALGORITHM=t,exports.SECRET_ENVELOPE_VERSION=n,exports.decryptSecret=s,exports.decryptWithEnvKey=_,exports.encryptSecret=o,exports.encryptWithEnvKey=g,exports.getMaskedSecret=c,exports.getSecretEnvelopeStatus=l,exports.hasSecretEncryptionKey=m,exports.isEncryptedSecretEnvelope=i,exports.requireSecretEncryptionKey=h,exports.resolveSecretEncryptionKey=p,exports.tryDecryptWithEnvKey=v;
|
|
1
|
+
require("server-only");let e=require("node:crypto");var t=`aes-256-gcm`,n=1;function r(t){let n=t.trim();if(!n)throw Error(`A non-empty encryption secret is required to manage stored secrets.`);return(0,e.createHash)(`sha256`).update(n).digest()}function i(e){if(!e||typeof e!=`object`)return!1;let t=e;return t.algorithm===`aes-256-gcm`&&t.version===1&&typeof t.authTag==`string`&&typeof t.ciphertext==`string`&&typeof t.iv==`string`}function a(e){if(!i(e))throw Error(`Invalid encrypted secret payload.`);let t=e;return{algorithm:t.algorithm,authTag:t.authTag,ciphertext:t.ciphertext,iv:t.iv,last4:typeof t.last4==`string`?t.last4:``,updatedAt:typeof t.updatedAt==`string`?t.updatedAt:``,version:t.version}}function o(n){let i=n.value.trim();if(!i)throw Error(`A non-empty value is required to encrypt a secret.`);let a=r(n.encryptionSecret),o=(0,e.randomBytes)(12),s=(0,e.createCipheriv)(t,a,o),c=Buffer.concat([s.update(i,`utf8`),s.final()]);return{algorithm:t,authTag:s.getAuthTag().toString(`base64`),ciphertext:c.toString(`base64`),iv:o.toString(`base64`),last4:i.slice(-4),updatedAt:(n.now??new Date).toISOString(),version:1}}function s(n){let i=a(n.envelope),o=r(n.encryptionSecret);try{let n=(0,e.createDecipheriv)(t,o,Buffer.from(i.iv,`base64`));return n.setAuthTag(Buffer.from(i.authTag,`base64`)),Buffer.concat([n.update(Buffer.from(i.ciphertext,`base64`)),n.final()]).toString(`utf8`)}catch{throw Error(`Failed to decrypt stored secret.`)}}function c(e){let t=(e??``).trim();return t?`•••• ${t}`:`Stored secret`}function l(e){if(!i(e))return{hasStoredValue:!1,last4:null,maskedValue:null,updatedAt:null};let t=e,n=typeof t.last4==`string`&&t.last4?t.last4:null;return{hasStoredValue:!0,last4:n,maskedValue:c(n),updatedAt:typeof t.updatedAt==`string`&&t.updatedAt?t.updatedAt:null}}var u=`nextblock-secret-encryption-key-v1`;function d(){let t=process.env.SUPABASE_SERVICE_ROLE_KEY??process.env.SUPABASE_SECRET_KEY;return typeof t!=`string`||!t.trim()?null:(0,e.createHmac)(`sha256`,t.trim()).update(u).digest(`hex`)}function f(){let e=[],t=t=>{let n=typeof t==`string`?t.trim():``;n&&!e.includes(n)&&e.push(n)};return t(process.env.NEXTBLOCK_ENCRYPTION_KEY),t(process.env.CORTEX_AI_ENCRYPTION_KEY),t(d()),e}function p(){return f()[0]??null}function m(){return p()!==null}function h(){let e=p();if(!e)throw Error(`An encryption key (NEXTBLOCK_ENCRYPTION_KEY or CORTEX_AI_ENCRYPTION_KEY) is required to manage stored secrets.`);return e}function g(e,t){return o({value:e,encryptionSecret:h(),now:t})}function _(e){return s({envelope:e,encryptionSecret:h()})}function v(e){if(!i(e))return null;for(let t of f())try{return s({envelope:e,encryptionSecret:t})}catch{}return null}exports.SECRET_ENVELOPE_ALGORITHM=t,exports.SECRET_ENVELOPE_VERSION=n,exports.decryptSecret=s,exports.decryptWithEnvKey=_,exports.encryptSecret=o,exports.encryptWithEnvKey=g,exports.getMaskedSecret=c,exports.getSecretEnvelopeStatus=l,exports.hasSecretEncryptionKey=m,exports.isEncryptedSecretEnvelope=i,exports.requireSecretEncryptionKey=h,exports.resolveSecretEncryptionKey=p,exports.tryDecryptWithEnvKey=v;
|
package/lib/secret-crypto.es.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
let e=require("@supabase/ssr")
|
|
1
|
+
let e=require("@supabase/ssr");require("server-only");let t=require("@supabase/supabase-js"),n=require("next/headers");var r=`This module cannot be imported from a Client Component module. It should only be used from a Server Component.`;if(typeof window<`u`)throw Error(r);var i=()=>{let t=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL||`https://dummy.supabase.co`,r=process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY||process.env.SUPABASE_ANON_KEY||process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY||process.env.SUPABASE_PUBLISHABLE_KEY||`dummy-anon-key`;return(0,e.createServerClient)(t,r,{cookies:{getAll:async()=>{try{return(await(0,n.cookies)()).getAll()}catch{return[]}},setAll:async e=>{try{let t=await(0,n.cookies)();for(let{name:n,value:r,options:i}of e)r&&r.length>0?t.set({name:n,value:r,...i}):i&&Object.keys(i).length>0?t.delete({name:n,...i}):t.delete(n)}catch{}}}})};async function a(e){let{data:t,error:n}=await i().from(`profiles`).select(`id, full_name, avatar_url, role, updated_at, website`).eq(`id`,e).single();return n||!t?(console.error(`Error fetching profile (server-side):`,n?.message),null):t}async function o(){let{data:e,error:t}=await i().from(`languages`).select(`id, code, name, is_default, is_active, created_at, updated_at`).order(`name`,{ascending:!0});return t?(console.error(`Error fetching languages (server-side):`,t.message),[]):e||[]}var s=()=>{let e=process.env.NEXT_PUBLIC_SUPABASE_URL||process.env.SUPABASE_URL,n=process.env.SUPABASE_SERVICE_ROLE_KEY||process.env.SUPABASE_SECRET_KEY;if(!e||!n)throw Error(`Missing Supabase Service Role environment variables`);return(0,t.createClient)(e,n,{auth:{autoRefreshToken:!1,persistSession:!1}})};exports.createClient=i,exports.getActiveLanguagesServerSide=o,exports.getProfileWithRoleServerSide=a,exports.getServiceRoleSupabaseClient=s;
|
package/package.json
CHANGED
package/server.cjs.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./lib/supabase/server.cjs.js"),t=require("./lib/supabase/ssg-client.cjs.js"),
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./lib/supabase/server.cjs.js"),t=require("./lib/media-record.cjs.js"),n=require("./lib/supabase/ssg-client.cjs.js"),r=require("./lib/package-validation.cjs.js"),i=require("./lib/secret-crypto.cjs.js"),a=require("./lib/config-resolve.cjs.js");exports.PACKAGE_ACTIVATION_CACHE_TAG=r.PACKAGE_ACTIVATION_CACHE_TAG,exports.SECRET_ENVELOPE_ALGORITHM=i.SECRET_ENVELOPE_ALGORITHM,exports.SECRET_ENVELOPE_VERSION=i.SECRET_ENVELOPE_VERSION,exports.createClient=e.createClient,exports.decryptSecret=i.decryptSecret,exports.decryptWithEnvKey=i.decryptWithEnvKey,exports.encryptSecret=i.encryptSecret,exports.encryptWithEnvKey=i.encryptWithEnvKey,exports.getActiveLanguagesServerSide=e.getActiveLanguagesServerSide,exports.getMaskedSecret=i.getMaskedSecret,exports.getProfileWithRoleServerSide=e.getProfileWithRoleServerSide,exports.getSecretEnvelopeStatus=i.getSecretEnvelopeStatus,exports.getServiceRoleSupabaseClient=e.getServiceRoleSupabaseClient,exports.getSsgSupabaseClient=n.getSsgSupabaseClient,exports.hasSecretEncryptionKey=i.hasSecretEncryptionKey,exports.isEncryptedSecretEnvelope=i.isEncryptedSecretEnvelope,exports.isPackageActivationRowValid=r.isPackageActivationRowValid,exports.isSandboxEnvironment=a.isSandboxEnvironment,exports.recordMediaUploadAsActor=t.recordMediaUploadAsActor,exports.requireSecretEncryptionKey=i.requireSecretEncryptionKey,exports.resolveConfigValue=a.resolveConfigValue,exports.resolvePackageActivationExpiry=r.resolvePackageActivationExpiry,exports.resolveSecretEncryptionKey=i.resolveSecretEncryptionKey,exports.tryDecryptWithEnvKey=i.tryDecryptWithEnvKey,exports.verifyPackageOnline=r.verifyPackageOnline;
|
package/server.d.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { getSsgSupabaseClient } from './lib/supabase/ssg-client';
|
|
|
3
3
|
export * from './lib/package-validation';
|
|
4
4
|
export * from './lib/secret-crypto';
|
|
5
5
|
export * from './lib/config-resolve';
|
|
6
|
+
export { recordMediaUploadAsActor, type ImageVariant, type MediaRecordResult, type MediaUploadPayload, } from './lib/media-record';
|
package/server.es.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createClient as e, getActiveLanguagesServerSide as t, getProfileWithRoleServerSide as n, getServiceRoleSupabaseClient as r } from "./lib/supabase/server.es.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
2
|
+
import { recordMediaUploadAsActor as i } from "./lib/media-record.es.js";
|
|
3
|
+
import { getSsgSupabaseClient as a } from "./lib/supabase/ssg-client.es.js";
|
|
4
|
+
import { PACKAGE_ACTIVATION_CACHE_TAG as o, isPackageActivationRowValid as s, resolvePackageActivationExpiry as c, verifyPackageOnline as l } from "./lib/package-validation.es.js";
|
|
5
|
+
import { SECRET_ENVELOPE_ALGORITHM as u, SECRET_ENVELOPE_VERSION as d, decryptSecret as f, decryptWithEnvKey as p, encryptSecret as m, encryptWithEnvKey as h, getMaskedSecret as g, getSecretEnvelopeStatus as _, hasSecretEncryptionKey as v, isEncryptedSecretEnvelope as y, requireSecretEncryptionKey as b, resolveSecretEncryptionKey as x, tryDecryptWithEnvKey as S } from "./lib/secret-crypto.es.js";
|
|
6
|
+
import { isSandboxEnvironment as C, resolveConfigValue as w } from "./lib/config-resolve.es.js";
|
|
7
|
+
export { o as PACKAGE_ACTIVATION_CACHE_TAG, u as SECRET_ENVELOPE_ALGORITHM, d as SECRET_ENVELOPE_VERSION, e as createClient, f as decryptSecret, p as decryptWithEnvKey, m as encryptSecret, h as encryptWithEnvKey, t as getActiveLanguagesServerSide, g as getMaskedSecret, n as getProfileWithRoleServerSide, _ as getSecretEnvelopeStatus, r as getServiceRoleSupabaseClient, a as getSsgSupabaseClient, v as hasSecretEncryptionKey, y as isEncryptedSecretEnvelope, s as isPackageActivationRowValid, C as isSandboxEnvironment, i as recordMediaUploadAsActor, b as requireSecretEncryptionKey, w as resolveConfigValue, c as resolvePackageActivationExpiry, x as resolveSecretEncryptionKey, S as tryDecryptWithEnvKey, l as verifyPackageOnline };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
-- 02025: grant-level hardening for the API roles (2026-10-21 posture review of 02021-02024).
|
|
2
|
+
--
|
|
3
|
+
-- Grants decide whether a role may touch a table at all; RLS decides which rows. This file
|
|
4
|
+
-- removes three places where RLS had become the only barrier. It changes no row a legitimate
|
|
5
|
+
-- request can reach: every statement below takes away a privilege that no RLS policy lets the
|
|
6
|
+
-- role use.
|
|
7
|
+
--
|
|
8
|
+
-- 1. anon is read-only at the grant level.
|
|
9
|
+
-- Supabase's historical default privileges, and the baseline (02003), granted anon
|
|
10
|
+
-- INSERT/UPDATE/DELETE/REFERENCES/TRIGGER on every table in `public`. No policy in the
|
|
11
|
+
-- schema lets anon write anything (checked against pg_policies on 2026-10-21: zero
|
|
12
|
+
-- INSERT/UPDATE/DELETE/ALL policies reach anon or PUBLIC), so those grants only meant that
|
|
13
|
+
-- a single permissive policy added by mistake would publish a table for writing to anyone
|
|
14
|
+
-- with the public key. Every anonymous write path in the app (forms, consent, comments,
|
|
15
|
+
-- checkout) already goes through a server route with the service role. Sequence USAGE and
|
|
16
|
+
-- UPDATE go too: without INSERT, anon never draws a value.
|
|
17
|
+
--
|
|
18
|
+
-- 2. cortex_request_log is service-role only again.
|
|
19
|
+
-- 02022 created it with REVOKE ALL FROM anon, authenticated and no API policy. The
|
|
20
|
+
-- post-apply pass of both apply paths (lib/setup/schema-apply.ts and the build hook in
|
|
21
|
+
-- tools/lib/migrate-core.mjs) then ran `grant all on all tables ... to authenticated` and
|
|
22
|
+
-- `grant select ... to anon` on every deploy, handing the table back to both roles. RLS
|
|
23
|
+
-- still returned no row, but a signed-in WRITER could issue DELETEs against the counter
|
|
24
|
+
-- that rate-limits the AI routes, and tools/scripts/security-verify.sql failed three checks
|
|
25
|
+
-- on any deployed database. Both apply paths now repeat this revoke (API_ROLE_REVOKE_SQL).
|
|
26
|
+
--
|
|
27
|
+
-- 3. profiles.role is pinned by grants, not only by policy and trigger.
|
|
28
|
+
-- The column was protected by profiles_update_policy's WITH CHECK and the
|
|
29
|
+
-- protect_profile_role trigger (02021). authenticated now holds INSERT and UPDATE on every
|
|
30
|
+
-- profiles column EXCEPT `role` (and `id` on UPDATE), so a policy regression can no longer
|
|
31
|
+
-- reopen self-promotion to ADMIN. User-session writes only ever touch full_name,
|
|
32
|
+
-- avatar_url, website, phone and updated_at (account page, checkout autofill); role changes
|
|
33
|
+
-- go through the service role in app/cms/users/actions.ts, which these grants do not
|
|
34
|
+
-- affect. DELETE, REFERENCES and TRIGGER on profiles are revoked from the API roles: no
|
|
35
|
+
-- policy grants DELETE to them and account deletion runs through auth.admin. The column
|
|
36
|
+
-- list is read from the catalog so a column added later keeps today's behaviour.
|
|
37
|
+
--
|
|
38
|
+
-- Both apply paths repeat all three after their blanket grants, so a deploy cannot undo
|
|
39
|
+
-- them; tools/scripts/security-verify.sql asserts them. Idempotent: safe to re-run.
|
|
40
|
+
|
|
41
|
+
-- 1. anon: read-only at the grant level
|
|
42
|
+
REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public FROM anon;
|
|
43
|
+
REVOKE USAGE, UPDATE ON ALL SEQUENCES IN SCHEMA public FROM anon;
|
|
44
|
+
|
|
45
|
+
-- 2. cortex_request_log: service_role only (its SELECT, INSERT, DELETE grant from 02022 stays)
|
|
46
|
+
REVOKE ALL ON TABLE public.cortex_request_log FROM PUBLIC, anon, authenticated;
|
|
47
|
+
|
|
48
|
+
-- 3. profiles: every column but `role` (and `id` on UPDATE) for authenticated
|
|
49
|
+
DO $nb_profiles$
|
|
50
|
+
DECLARE
|
|
51
|
+
v_insert_cols text;
|
|
52
|
+
v_update_cols text;
|
|
53
|
+
BEGIN
|
|
54
|
+
SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FILTER (WHERE a.attname <> 'role'),
|
|
55
|
+
string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum) FILTER (WHERE a.attname NOT IN ('id', 'role'))
|
|
56
|
+
INTO v_insert_cols, v_update_cols
|
|
57
|
+
FROM pg_attribute a
|
|
58
|
+
WHERE a.attrelid = 'public.profiles'::regclass AND a.attnum > 0 AND NOT a.attisdropped;
|
|
59
|
+
|
|
60
|
+
-- Revoking the table-level privilege also revokes every column-level grant of that kind,
|
|
61
|
+
-- so the column grants below are the complete list afterwards.
|
|
62
|
+
REVOKE INSERT, UPDATE, DELETE, REFERENCES, TRIGGER ON TABLE public.profiles FROM PUBLIC, anon, authenticated;
|
|
63
|
+
EXECUTE format('GRANT INSERT (%s) ON TABLE public.profiles TO authenticated', v_insert_cols);
|
|
64
|
+
EXECUTE format('GRANT UPDATE (%s) ON TABLE public.profiles TO authenticated', v_update_cols);
|
|
65
|
+
END
|
|
66
|
+
$nb_profiles$;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- 02026: authenticated holds no table write privilege that no RLS policy lets it use
|
|
2
|
+
-- (2026-10-21, the follow-up 02025 left open).
|
|
3
|
+
--
|
|
4
|
+
-- 02025 made anon read-only at the grant level. authenticated still held SELECT, INSERT,
|
|
5
|
+
-- UPDATE, DELETE, REFERENCES and TRIGGER on every table in `public` (the baseline 02003, then
|
|
6
|
+
-- `grant all on all tables in schema public to authenticated` in the post-apply pass on every
|
|
7
|
+
-- deploy). On the tables below RLS was the only barrier between a signed-in session and a
|
|
8
|
+
-- write: no permissive policy for authenticated or PUBLIC covers the listed commands, only a
|
|
9
|
+
-- `TO service_role` policy or none at all. Every app write to them runs server-side with the
|
|
10
|
+
-- service role after its own checks: 2FA challenges, trusted devices and security settings
|
|
11
|
+
-- (lib/auth, settings/security), consent logs, contact-seller enquiries and message threads,
|
|
12
|
+
-- UCP carts, coupon redemptions (checkout providers), license activations (lib/packages),
|
|
13
|
+
-- the Freemius catalog sync, the update checker's alerts and Cortex's mutation audit.
|
|
14
|
+
--
|
|
15
|
+
-- Behaviour-neutral by construction: authenticated could never use a revoked privilege. Its
|
|
16
|
+
-- INSERT failed the RLS check and its UPDATE or DELETE matched no row; now the grant refuses
|
|
17
|
+
-- the statement before RLS is consulted, and no row any request reaches changes. (One CMS
|
|
18
|
+
-- action, updateFreemiusOverride in libs/ecommerce, updates freemius_pricing through the
|
|
19
|
+
-- cookie client: RLS has always discarded that UPDATE while the action reported success, and
|
|
20
|
+
-- it now reports the permission error instead.) Every command that has a policy keeps its
|
|
21
|
+
-- grant: the ADMIN UPDATE on message_threads, product_inquiries and system_alerts, the ADMIN
|
|
22
|
+
-- INSERT on site_script_revisions, the owner DELETE on user_trusted_devices, and every SELECT.
|
|
23
|
+
-- The pre-flight block re-checks that against pg_policies on the target database and aborts
|
|
24
|
+
-- without changing anything if a policy has appeared since this list was taken (a replay
|
|
25
|
+
-- through 02025 plus the post-apply pass, 2026-10-21).
|
|
26
|
+
--
|
|
27
|
+
-- REFERENCES and TRIGGER go from every table, as 02025 did for anon: they only let a role
|
|
28
|
+
-- create a foreign key or a trigger, and no API request runs DDL. Foreign-key actions are not
|
|
29
|
+
-- affected by any of this: ON DELETE CASCADE / SET NULL run as the owner of the referencing
|
|
30
|
+
-- table, so an ADMIN deleting a product still removes its freemius_plans and freemius_pricing
|
|
31
|
+
-- rows, and deleting an order still removes its coupon_redemptions.
|
|
32
|
+
--
|
|
33
|
+
-- Both apply paths repeat these revokes after their blanket grants (API_ROLE_REVOKE_SQL). To
|
|
34
|
+
-- give authenticated one of these privileges later, add the policy and the GRANT in the same
|
|
35
|
+
-- migration and take the table out of both copies of that list, or the next deploy revokes it
|
|
36
|
+
-- again. tools/scripts/security-verify.sql sections 26-27 fail on a write grant no policy uses
|
|
37
|
+
-- and on a write policy whose grant is missing. Idempotent: safe to re-run.
|
|
38
|
+
|
|
39
|
+
DO $nb_preflight$
|
|
40
|
+
DECLARE
|
|
41
|
+
offenders text;
|
|
42
|
+
BEGIN
|
|
43
|
+
SELECT string_agg(format('%s %s (policy %s)', l.tbl, r.cmd, p.policyname), ', ' ORDER BY l.tbl, r.cmd, p.policyname)
|
|
44
|
+
INTO offenders
|
|
45
|
+
FROM (VALUES
|
|
46
|
+
('cortex_ai_db_mutation_audit', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
47
|
+
('coupon_redemptions', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
48
|
+
('email_2fa_challenges', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
49
|
+
('freemius_plans', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
50
|
+
('freemius_pricing', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
51
|
+
('package_activations', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
52
|
+
('privacy_consent_logs', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
53
|
+
('thread_messages', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
54
|
+
('ucp_cart_sessions', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
55
|
+
('user_security_settings', ARRAY['INSERT', 'UPDATE', 'DELETE']),
|
|
56
|
+
('message_threads', ARRAY['INSERT', 'DELETE']),
|
|
57
|
+
('product_inquiries', ARRAY['INSERT', 'DELETE']),
|
|
58
|
+
('system_alerts', ARRAY['INSERT', 'DELETE']),
|
|
59
|
+
('site_script_revisions', ARRAY['UPDATE', 'DELETE']),
|
|
60
|
+
('user_trusted_devices', ARRAY['INSERT', 'UPDATE'])
|
|
61
|
+
) AS l(tbl, cmds)
|
|
62
|
+
CROSS JOIN LATERAL unnest(l.cmds) AS r(cmd)
|
|
63
|
+
JOIN pg_policies p
|
|
64
|
+
ON p.schemaname = 'public' AND p.tablename = l.tbl
|
|
65
|
+
AND p.permissive = 'PERMISSIVE' AND p.cmd IN (r.cmd, 'ALL')
|
|
66
|
+
AND p.roles && ARRAY['authenticated', 'public']::name[];
|
|
67
|
+
IF offenders IS NOT NULL THEN
|
|
68
|
+
RAISE EXCEPTION '02026 would revoke a privilege that an RLS policy lets authenticated use: %. Nothing was changed; drop that table/command from this file and from API_ROLE_REVOKE_SQL, or drop the policy.', offenders;
|
|
69
|
+
END IF;
|
|
70
|
+
END
|
|
71
|
+
$nb_preflight$;
|
|
72
|
+
|
|
73
|
+
-- 1. writes that no policy lets authenticated use
|
|
74
|
+
REVOKE INSERT, UPDATE, DELETE ON TABLE
|
|
75
|
+
public.cortex_ai_db_mutation_audit,
|
|
76
|
+
public.coupon_redemptions,
|
|
77
|
+
public.email_2fa_challenges,
|
|
78
|
+
public.freemius_plans,
|
|
79
|
+
public.freemius_pricing,
|
|
80
|
+
public.package_activations,
|
|
81
|
+
public.privacy_consent_logs,
|
|
82
|
+
public.thread_messages,
|
|
83
|
+
public.ucp_cart_sessions,
|
|
84
|
+
public.user_security_settings
|
|
85
|
+
FROM authenticated;
|
|
86
|
+
REVOKE INSERT, DELETE ON TABLE public.message_threads, public.product_inquiries, public.system_alerts FROM authenticated;
|
|
87
|
+
REVOKE UPDATE, DELETE ON TABLE public.site_script_revisions FROM authenticated;
|
|
88
|
+
REVOKE INSERT, UPDATE ON TABLE public.user_trusted_devices FROM authenticated;
|
|
89
|
+
|
|
90
|
+
-- 2. REFERENCES / TRIGGER: DDL-only, unused by every API request
|
|
91
|
+
REVOKE REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public FROM authenticated;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
-- 02027: anon and authenticated hold SELECT only where an RLS policy lets them read
|
|
2
|
+
-- (2026-10-21, the read side of 02025/02026).
|
|
3
|
+
--
|
|
4
|
+
-- 02025 made anon read-only and 02026 left authenticated only the writes some policy uses.
|
|
5
|
+
-- SELECT was still granted to both roles on every table in `public` (the baseline 02003, then
|
|
6
|
+
-- `grant select on all tables in schema public to anon` and `grant all ... to authenticated` in
|
|
7
|
+
-- the post-apply pass on every deploy). On the tables below no permissive SELECT or ALL policy
|
|
8
|
+
-- reaches the role (or PUBLIC), so RLS was the only barrier between the public key and orders,
|
|
9
|
+
-- profiles, coupons, licenses, MCP tokens, revisions, drafts, 2FA challenges, carts and
|
|
10
|
+
-- messages: one permissive policy added by mistake, or a table created with RLS off, would
|
|
11
|
+
-- have published it to anyone holding that key.
|
|
12
|
+
--
|
|
13
|
+
-- anon: every table that only staff, an owner or the service role reads (29, listed below).
|
|
14
|
+
-- authenticated: email_2fa_challenges and ucp_cart_sessions, whose only policy is TO
|
|
15
|
+
-- service_role (lib/auth/twoFactor and the UCP routes read them with the service role;
|
|
16
|
+
-- 02026 already took authenticated's writes on both).
|
|
17
|
+
-- profile_cards (02021): anon loses SELECT, authenticated loses INSERT/UPDATE/DELETE. It is a
|
|
18
|
+
-- security_invoker view over profiles, so anon could only ever read it through a SELECT on
|
|
19
|
+
-- profiles, which goes here. The public author card does not read the view: author()
|
|
20
|
+
-- returns its row type from private.author_card(), a SECURITY DEFINER function, so
|
|
21
|
+
-- `cms_interactions?select=*,author(full_name,avatar_url)` keeps working for anon (checked
|
|
22
|
+
-- through PostgREST on 2026-10-21). The view is a read card; nothing writes through it.
|
|
23
|
+
--
|
|
24
|
+
-- Unlike a write, a SELECT can be needed on a table the request never names: a policy
|
|
25
|
+
-- subquery (`EXISTS (SELECT ... FROM x)`), a security_invoker view, an invoker function or a
|
|
26
|
+
-- PostgREST embed (`select=*,x(...)`) all read x as the calling role, and they fail with 42501
|
|
27
|
+
-- once the grant is gone, where RLS used to return no row (or a null embed). The census behind
|
|
28
|
+
-- this list (a replay through 02026 plus the post-apply pass, 2026-10-21) found:
|
|
29
|
+
-- - one policy that reads another of these tables, order_items' owner policy (orders): it is
|
|
30
|
+
-- TO authenticated, which keeps SELECT on orders;
|
|
31
|
+
-- - one view, profile_cards (above);
|
|
32
|
+
-- - invoker functions reading them: apply_order_inventory_deduction (orders, order_items),
|
|
33
|
+
-- called by the app only with the service role and already refused to anon by its
|
|
34
|
+
-- SELECT ... FOR UPDATE since 02025 (FOR UPDATE needs UPDATE), and the trigger functions of
|
|
35
|
+
-- site_script_revisions / thread_messages, which fire only on writes anon cannot make;
|
|
36
|
+
-- - no anonymous query in the app (getSsgSupabaseClient, the browser client, the cookie client
|
|
37
|
+
-- on signed-out paths) embeds a listed table; the public author card goes through author().
|
|
38
|
+
-- The anonymous paths that need one of these tables (checkout success, order access tokens,
|
|
39
|
+
-- contact seller, threads, consent, forms, UCP, MCP tokens, the sign-up read of
|
|
40
|
+
-- system_configuration) read it with the service role after their own checks. The few
|
|
41
|
+
-- cookie-client reads a signed-out request can reach (a draft lookup in draft mode or with
|
|
42
|
+
-- NEXTBLOCK_VISUAL_EDITING_ENABLED, verifyPackageOnline on an install with no service key,
|
|
43
|
+
-- the MCP route's revision capture, a custom block's `profiles` relation) already got no
|
|
44
|
+
-- row and handle the error the same way: no draft, not licensed, no revision, no record.
|
|
45
|
+
-- Behaviour-neutral for every request that worked: an anon or authenticated read of a listed
|
|
46
|
+
-- table returned no row before and is refused by the grant now. The pre-flight block re-checks
|
|
47
|
+
-- the catalog side on the target database and aborts without changing anything if a policy
|
|
48
|
+
-- lets the role read a listed table, if a policy the role is subject to reads one, or if a view
|
|
49
|
+
-- the role can read depends on one.
|
|
50
|
+
--
|
|
51
|
+
-- Both apply paths repeat these revokes after their blanket grants (API_ROLE_REVOKE_SQL). To let
|
|
52
|
+
-- one of these roles read a listed table later, add the policy and GRANT SELECT in the same
|
|
53
|
+
-- migration and take the table out of both copies of that list, or the next deploy revokes it
|
|
54
|
+
-- again. tools/scripts/security-verify.sql sections 28-29 fail on a SELECT grant no policy uses
|
|
55
|
+
-- and on a read policy whose grant is missing. Idempotent: safe to re-run.
|
|
56
|
+
|
|
57
|
+
DO $nb_preflight$
|
|
58
|
+
DECLARE
|
|
59
|
+
offenders text;
|
|
60
|
+
BEGIN
|
|
61
|
+
WITH l(role, tbl) AS (
|
|
62
|
+
SELECT 'anon'::name, t
|
|
63
|
+
FROM unnest(ARRAY[
|
|
64
|
+
'content_drafts', 'cortex_ai_db_mutation_audit', 'coupon_freemius_mappings',
|
|
65
|
+
'coupon_products', 'coupon_redemptions', 'coupons', 'email_2fa_challenges',
|
|
66
|
+
'form_endpoints', 'mcp_access_tokens', 'message_threads', 'order_items', 'orders',
|
|
67
|
+
'package_activations', 'page_revisions', 'post_revisions', 'privacy_consent_logs',
|
|
68
|
+
'product_drafts', 'product_freemius_sale_coupons', 'product_inquiries',
|
|
69
|
+
'product_revisions', 'profile_cards', 'profiles', 'site_script_revisions',
|
|
70
|
+
'system_alerts', 'system_configuration', 'thread_messages', 'ucp_cart_sessions',
|
|
71
|
+
'user_addresses', 'user_security_settings', 'user_trusted_devices']::name[]) AS t
|
|
72
|
+
UNION ALL
|
|
73
|
+
SELECT 'authenticated'::name, t
|
|
74
|
+
FROM unnest(ARRAY['email_2fa_challenges', 'ucp_cart_sessions']::name[]) AS t
|
|
75
|
+
), r AS (
|
|
76
|
+
SELECT l.role, l.tbl, to_regclass('public.' || quote_ident(l.tbl)) AS oid FROM l
|
|
77
|
+
), hits(what) AS (
|
|
78
|
+
-- a policy that lets the role read a listed table
|
|
79
|
+
SELECT format('%s reads %s through policy %s', r.role, r.tbl, p.policyname)
|
|
80
|
+
FROM r
|
|
81
|
+
JOIN pg_policies p
|
|
82
|
+
ON p.schemaname = 'public' AND p.tablename = r.tbl
|
|
83
|
+
AND p.permissive = 'PERMISSIVE' AND p.cmd IN ('SELECT', 'ALL')
|
|
84
|
+
AND p.roles && ARRAY[r.role, 'public']::name[]
|
|
85
|
+
UNION ALL
|
|
86
|
+
-- a policy the role is subject to, on any other table, whose expression reads a listed table
|
|
87
|
+
SELECT format('policy %s on %s reads %s as %s', pol.polname, pol.polrelid::regclass, r.tbl, r.role)
|
|
88
|
+
FROM r
|
|
89
|
+
JOIN pg_depend d
|
|
90
|
+
ON d.classid = 'pg_policy'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid = r.oid
|
|
91
|
+
JOIN pg_policy pol ON pol.oid = d.objid
|
|
92
|
+
WHERE pol.polrelid <> r.oid
|
|
93
|
+
AND (0::oid = ANY (pol.polroles) OR (SELECT oid FROM pg_roles WHERE rolname = r.role) = ANY (pol.polroles))
|
|
94
|
+
UNION ALL
|
|
95
|
+
-- a view the role keeps SELECT on that reads a listed table
|
|
96
|
+
SELECT format('view %s reads %s and stays readable by %s', rw.ev_class::regclass, r.tbl, r.role)
|
|
97
|
+
FROM r
|
|
98
|
+
JOIN pg_depend d
|
|
99
|
+
ON d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid = r.oid
|
|
100
|
+
JOIN pg_rewrite rw ON rw.oid = d.objid
|
|
101
|
+
WHERE rw.ev_class <> r.oid
|
|
102
|
+
AND has_table_privilege(r.role, rw.ev_class, 'SELECT')
|
|
103
|
+
AND NOT EXISTS (SELECT 1 FROM r r2 WHERE r2.role = r.role AND r2.oid = rw.ev_class)
|
|
104
|
+
)
|
|
105
|
+
SELECT string_agg(DISTINCT what, '; ') INTO offenders FROM hits;
|
|
106
|
+
IF offenders IS NOT NULL THEN
|
|
107
|
+
RAISE EXCEPTION '02027 would revoke a SELECT that a request still needs: %. Nothing was changed; drop that table from this file and from API_ROLE_REVOKE_SQL, or change the policy or view.', offenders;
|
|
108
|
+
END IF;
|
|
109
|
+
END
|
|
110
|
+
$nb_preflight$;
|
|
111
|
+
|
|
112
|
+
-- 1. anon: SELECT on every table no policy lets anon read
|
|
113
|
+
REVOKE SELECT ON TABLE
|
|
114
|
+
public.content_drafts,
|
|
115
|
+
public.cortex_ai_db_mutation_audit,
|
|
116
|
+
public.coupon_freemius_mappings,
|
|
117
|
+
public.coupon_products,
|
|
118
|
+
public.coupon_redemptions,
|
|
119
|
+
public.coupons,
|
|
120
|
+
public.email_2fa_challenges,
|
|
121
|
+
public.form_endpoints,
|
|
122
|
+
public.mcp_access_tokens,
|
|
123
|
+
public.message_threads,
|
|
124
|
+
public.order_items,
|
|
125
|
+
public.orders,
|
|
126
|
+
public.package_activations,
|
|
127
|
+
public.page_revisions,
|
|
128
|
+
public.post_revisions,
|
|
129
|
+
public.privacy_consent_logs,
|
|
130
|
+
public.product_drafts,
|
|
131
|
+
public.product_freemius_sale_coupons,
|
|
132
|
+
public.product_inquiries,
|
|
133
|
+
public.product_revisions,
|
|
134
|
+
public.profiles,
|
|
135
|
+
public.site_script_revisions,
|
|
136
|
+
public.system_alerts,
|
|
137
|
+
public.system_configuration,
|
|
138
|
+
public.thread_messages,
|
|
139
|
+
public.ucp_cart_sessions,
|
|
140
|
+
public.user_addresses,
|
|
141
|
+
public.user_security_settings,
|
|
142
|
+
public.user_trusted_devices
|
|
143
|
+
FROM anon;
|
|
144
|
+
|
|
145
|
+
-- 2. authenticated: the two tables only the service role reads
|
|
146
|
+
REVOKE SELECT ON TABLE public.email_2fa_challenges, public.ucp_cart_sessions FROM authenticated;
|
|
147
|
+
|
|
148
|
+
-- 3. profile_cards: a read card for staff and owners (through profiles' policies)
|
|
149
|
+
REVOKE SELECT ON TABLE public.profile_cards FROM anon;
|
|
150
|
+
REVOKE INSERT, UPDATE, DELETE ON TABLE public.profile_cards FROM authenticated;
|
package/supabase/migrations/02028_writer_settings_allowlist_email_factor_aal_and_media_keys.sql
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
-- 02028: close the database holes the 2026-10-21 audit confirmed on a local replay (each one
|
|
2
|
+
-- reproduced over PostgREST: SET LOCAL ROLE authenticated + request.jwt.claims, rolled back).
|
|
3
|
+
-- Append-only, forward-only, idempotent: every policy is dropped by name and recreated, every
|
|
4
|
+
-- function is CREATE OR REPLACE, every grant is safe to repeat. 02022/02023 are the model.
|
|
5
|
+
--
|
|
6
|
+
-- 1. site_settings: the WRITER write branch becomes an allow-list (HIGH).
|
|
7
|
+
-- 02022/02023 let ADMIN *or WRITER* write every key outside a 13-key deny-list. A WRITER
|
|
8
|
+
-- could therefore PATCH `email_public` to point SMTP at their own server, and
|
|
9
|
+
-- lib/config/email-settings.ts combines that host with the decrypted ADMIN-only
|
|
10
|
+
-- `email_secret` credentials: the SMTP password and every outgoing mail (ADMIN email-2FA
|
|
11
|
+
-- codes included) went to the attacker. The same branch let a WRITER turn the captcha off
|
|
12
|
+
-- (`bot_protection_public`), rewrite payment and invoice settings, redirect the store,
|
|
13
|
+
-- form and moderation notification addresses, change `migration_baseline_generation`
|
|
14
|
+
-- (which 02000 trusts) and the Cortex agent settings. An unfiltered PATCH updated 13 rows.
|
|
15
|
+
-- Writes now fall into three tiers, and a key in none of the first two is in the third:
|
|
16
|
+
-- * WRITER tier (ADMIN or WRITER, first factor): exactly the keys staff code writes
|
|
17
|
+
-- through the cookie client — site identity (site_title, site_description,
|
|
18
|
+
-- site_keywords, site_social_image, active_logo_id: app/cms/settings/logos/actions.ts),
|
|
19
|
+
-- the footer (footer_copyright, footer_show_attribution: settings/copyright/actions.ts)
|
|
20
|
+
-- and onboarding_state (lib/onboarding/actions.ts, the dashboard's dismiss button).
|
|
21
|
+
-- * ADMIN tier (ADMIN, first factor): the two keys an ADMIN still writes through the
|
|
22
|
+
-- cookie client, enabled_payment_providers and ecommerce_inventory_settings
|
|
23
|
+
-- (libs/ecommerce payments, shipping, taxes). Moving those writes to the service role
|
|
24
|
+
-- lets them drop into the tier below (docs/04 lists it as a follow-up).
|
|
25
|
+
-- * everything else: ADMIN with a satisfied second factor (public.is_admin_mfa()). That
|
|
26
|
+
-- is the old deny-list plus email_public, bot_protection_public, payment_public,
|
|
27
|
+
-- invoice_settings, store_contact, forms_contact, interactions_notification_emails,
|
|
28
|
+
-- migration_baseline_generation, the cortex_ai_* settings and any key added later.
|
|
29
|
+
-- The app writes all of them with the service role after requireCmsAdmin() /
|
|
30
|
+
-- requireEcomAdmin(), Cortex too (its context is the service client); the three that
|
|
31
|
+
-- still used the cookie client move in the same change (logos/actions.ts
|
|
32
|
+
-- saveInvoiceSettings, cms/inquiries saveStoreContactEmail, the interactions
|
|
33
|
+
-- notification-address actions). The last two were already broken for an aal1 admin:
|
|
34
|
+
-- an upsert's ON CONFLICT path checks the read policy, and both keys sit above the
|
|
35
|
+
-- staff read tier. Reads are untouched (02021/02023 tiers).
|
|
36
|
+
--
|
|
37
|
+
-- 2. The email second factor counts in RLS (HIGH).
|
|
38
|
+
-- private.meets_aal_requirement() passed whenever the caller had no verified row in
|
|
39
|
+
-- auth.mfa_factors. The app's email factor never creates one (only TOTP does), so for an
|
|
40
|
+
-- email-2FA ADMIN every is_admin_mfa() policy was a plain role check: a stolen password and
|
|
41
|
+
-- an aal1 JWT reached mcp_access_tokens (a persistent MCP bearer), package_activations,
|
|
42
|
+
-- the secret tier of site_settings and other accounts' profiles. The no-factor branch now
|
|
43
|
+
-- also requires that the caller has no enrolled factor in public.user_security_settings
|
|
44
|
+
-- (`mfa_enabled AND mfa_type IS NOT NULL`, written only by the service role since 02026).
|
|
45
|
+
-- An email factor never yields an aal2 Supabase session, so for those admins every
|
|
46
|
+
-- is_admin_mfa() row is now reachable only through the service role, after the app's own
|
|
47
|
+
-- check (lib/auth/cms-actor.ts getCmsActor() refuses a session whose factor is pending).
|
|
48
|
+
-- A TOTP admin keeps aal2, and an admin who enrolled nothing is never locked out.
|
|
49
|
+
--
|
|
50
|
+
-- 3. ADMIN branches that skipped the second factor (MEDIUM).
|
|
51
|
+
-- site_scripts / site_script_revisions writes (scripts render with the CSP nonce on every
|
|
52
|
+
-- route, /cms included, so this was script injection into a real admin's session),
|
|
53
|
+
-- cms_redirects writes (an open redirect on any path), system_configuration (sign-up
|
|
54
|
+
-- policy) and the PII reads and updates on product_inquiries, message_threads and
|
|
55
|
+
-- thread_messages now use is_admin_mfa(). The CMS actions for all of them run on the
|
|
56
|
+
-- service role after requireCmsAdmin() (site scripts, redirects, registration/security,
|
|
57
|
+
-- messages); the public thread page and the contact paths already did. The public read
|
|
58
|
+
-- policies (active scripts, active redirects) are unchanged.
|
|
59
|
+
--
|
|
60
|
+
-- 4. media: uploader_id is pinned on insert, and storage keys in use are visible (MEDIUM).
|
|
61
|
+
-- media_insert_policy only checked the role, so a WRITER could insert a row attributed to
|
|
62
|
+
-- anyone; nothing pins object_key or variants either, and app/cms/media/actions.ts deleted
|
|
63
|
+
-- every variants[].objectKey of a deleted row with server credentials. A WRITER could
|
|
64
|
+
-- insert a decoy row listing other uploaders' keys and delete their files. Insert now pins
|
|
65
|
+
-- uploader_id to the caller (ADMIN keeps any). The delete and move actions keep only the
|
|
66
|
+
-- keys derived from the row's own object_key (lib/storage/media-object-keys.ts) and ask
|
|
67
|
+
-- public.media_storage_keys_in_use() which keys another row still references, so a shared
|
|
68
|
+
-- key is never deleted. The function is SECURITY INVOKER and service-role only: the app
|
|
69
|
+
-- calls it after its own role check, and the API roles have no use for it.
|
|
70
|
+
--
|
|
71
|
+
-- 5. package_activations: license keys are not readable over the API (MEDIUM, sandbox).
|
|
72
|
+
-- The public demo signs every visitor in as an ADMIN with no factor, so is_admin_mfa()
|
|
73
|
+
-- passes and "Staff read package activations" handed out the plaintext license key.
|
|
74
|
+
-- authenticated now holds SELECT on every column except license_key and meta (meta keeps
|
|
75
|
+
-- the Freemius activation response, install API token included). Every app read of the
|
|
76
|
+
-- table already uses the service role (packages page, dashboard, verifyPackageOnline,
|
|
77
|
+
-- revalidation, activation). Both apply paths repeat the column grant after their blanket
|
|
78
|
+
-- grants (API_ROLE_REVOKE_SQL), the same shape as profiles in 02025.
|
|
79
|
+
--
|
|
80
|
+
-- 6. media_read_policy stays USING (true) for anon (LOW, decision recorded).
|
|
81
|
+
-- The bucket is public-read and every rendered image carries its object URL, so a media
|
|
82
|
+
-- row discloses nothing the object URL does not, beyond its file name, description and
|
|
83
|
+
-- uploader id. Scoping it to "media referenced by live content" would have to follow every
|
|
84
|
+
-- reference (image blocks, section backgrounds, posts, products, logos, site_settings) and
|
|
85
|
+
-- would break images on any path it missed. docs/04 records the decision.
|
|
86
|
+
--
|
|
87
|
+
-- tools/scripts/security-verify.sql sections 30-34 check every item above, before and after.
|
|
88
|
+
|
|
89
|
+
-- ---------------------------------------------------------------------------
|
|
90
|
+
-- 1. site_settings: three write tiers, WRITER on an allow-list
|
|
91
|
+
-- ---------------------------------------------------------------------------
|
|
92
|
+
DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
|
|
93
|
+
CREATE POLICY site_settings_insert_policy ON public.site_settings
|
|
94
|
+
FOR INSERT TO authenticated
|
|
95
|
+
WITH CHECK (
|
|
96
|
+
((key = ANY (ARRAY['site_title','site_description','site_keywords','site_social_image','active_logo_id','footer_copyright','footer_show_attribution','onboarding_state']::text[]))
|
|
97
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
98
|
+
OR ((key = ANY (ARRAY['enabled_payment_providers','ecommerce_inventory_settings']::text[]))
|
|
99
|
+
AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
100
|
+
OR ((SELECT public.is_admin_mfa()) IS TRUE)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
|
|
104
|
+
CREATE POLICY site_settings_update_policy ON public.site_settings
|
|
105
|
+
FOR UPDATE TO authenticated
|
|
106
|
+
USING (
|
|
107
|
+
((key = ANY (ARRAY['site_title','site_description','site_keywords','site_social_image','active_logo_id','footer_copyright','footer_show_attribution','onboarding_state']::text[]))
|
|
108
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
109
|
+
OR ((key = ANY (ARRAY['enabled_payment_providers','ecommerce_inventory_settings']::text[]))
|
|
110
|
+
AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
111
|
+
OR ((SELECT public.is_admin_mfa()) IS TRUE)
|
|
112
|
+
)
|
|
113
|
+
WITH CHECK (
|
|
114
|
+
((key = ANY (ARRAY['site_title','site_description','site_keywords','site_social_image','active_logo_id','footer_copyright','footer_show_attribution','onboarding_state']::text[]))
|
|
115
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
116
|
+
OR ((key = ANY (ARRAY['enabled_payment_providers','ecommerce_inventory_settings']::text[]))
|
|
117
|
+
AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
118
|
+
OR ((SELECT public.is_admin_mfa()) IS TRUE)
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
|
|
122
|
+
CREATE POLICY site_settings_delete_policy ON public.site_settings
|
|
123
|
+
FOR DELETE TO authenticated
|
|
124
|
+
USING (
|
|
125
|
+
((key = ANY (ARRAY['site_title','site_description','site_keywords','site_social_image','active_logo_id','footer_copyright','footer_show_attribution','onboarding_state']::text[]))
|
|
126
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
127
|
+
OR ((key = ANY (ARRAY['enabled_payment_providers','ecommerce_inventory_settings']::text[]))
|
|
128
|
+
AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
|
|
129
|
+
OR ((SELECT public.is_admin_mfa()) IS TRUE)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
-- ---------------------------------------------------------------------------
|
|
133
|
+
-- 2. meets_aal_requirement(): an enrolled email factor counts as a factor
|
|
134
|
+
-- ---------------------------------------------------------------------------
|
|
135
|
+
-- True when the session is aal2, or when the caller enrolled no second factor at all: no
|
|
136
|
+
-- verified Supabase factor (TOTP) AND no enabled factor in user_security_settings (TOTP or the
|
|
137
|
+
-- app's email codes). Same signature, owner and grants as 02022; CREATE OR REPLACE keeps the
|
|
138
|
+
-- is_admin_mfa() wrapper and every policy that calls it.
|
|
139
|
+
CREATE OR REPLACE FUNCTION private.meets_aal_requirement()
|
|
140
|
+
RETURNS boolean
|
|
141
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
142
|
+
SET search_path TO ''
|
|
143
|
+
AS $$
|
|
144
|
+
SELECT COALESCE((auth.jwt() ->> 'aal'), 'aal1') = 'aal2'
|
|
145
|
+
OR (
|
|
146
|
+
NOT EXISTS (
|
|
147
|
+
SELECT 1 FROM auth.mfa_factors f
|
|
148
|
+
WHERE f.user_id = auth.uid() AND f.status = 'verified'
|
|
149
|
+
)
|
|
150
|
+
AND NOT EXISTS (
|
|
151
|
+
SELECT 1 FROM public.user_security_settings s
|
|
152
|
+
WHERE s.user_id = auth.uid() AND s.mfa_enabled AND s.mfa_type IS NOT NULL
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
$$;
|
|
156
|
+
REVOKE ALL ON FUNCTION private.meets_aal_requirement() FROM PUBLIC;
|
|
157
|
+
GRANT EXECUTE ON FUNCTION private.meets_aal_requirement() TO anon, authenticated, service_role;
|
|
158
|
+
|
|
159
|
+
-- ---------------------------------------------------------------------------
|
|
160
|
+
-- 3. Second factor on the remaining ADMIN branches
|
|
161
|
+
-- ---------------------------------------------------------------------------
|
|
162
|
+
-- site_scripts: INSERT / UPDATE / DELETE (the two read policies of 02013 are unchanged)
|
|
163
|
+
DROP POLICY IF EXISTS "Admins insert site scripts" ON public.site_scripts;
|
|
164
|
+
CREATE POLICY "Admins insert site scripts" ON public.site_scripts
|
|
165
|
+
FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
166
|
+
DROP POLICY IF EXISTS "Admins update site scripts" ON public.site_scripts;
|
|
167
|
+
CREATE POLICY "Admins update site scripts" ON public.site_scripts
|
|
168
|
+
FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
169
|
+
DROP POLICY IF EXISTS "Admins delete site scripts" ON public.site_scripts;
|
|
170
|
+
CREATE POLICY "Admins delete site scripts" ON public.site_scripts
|
|
171
|
+
FOR DELETE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
172
|
+
|
|
173
|
+
-- site_script_revisions: INSERT (UPDATE / DELETE have no policy and no grant since 02026)
|
|
174
|
+
DROP POLICY IF EXISTS "Admins insert site script revisions" ON public.site_script_revisions;
|
|
175
|
+
CREATE POLICY "Admins insert site script revisions" ON public.site_script_revisions
|
|
176
|
+
FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
177
|
+
|
|
178
|
+
-- cms_redirects: INSERT / UPDATE / DELETE (the two read policies of 02013 are unchanged)
|
|
179
|
+
DROP POLICY IF EXISTS "Admins insert redirects" ON public.cms_redirects;
|
|
180
|
+
CREATE POLICY "Admins insert redirects" ON public.cms_redirects
|
|
181
|
+
FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
182
|
+
DROP POLICY IF EXISTS "Admins update redirects" ON public.cms_redirects;
|
|
183
|
+
CREATE POLICY "Admins update redirects" ON public.cms_redirects
|
|
184
|
+
FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
185
|
+
DROP POLICY IF EXISTS "Admins delete redirects" ON public.cms_redirects;
|
|
186
|
+
CREATE POLICY "Admins delete redirects" ON public.cms_redirects
|
|
187
|
+
FOR DELETE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
188
|
+
|
|
189
|
+
-- system_configuration: every command (the sign-up path reads it with the service role)
|
|
190
|
+
DROP POLICY IF EXISTS system_configuration_admin_select ON public.system_configuration;
|
|
191
|
+
CREATE POLICY system_configuration_admin_select ON public.system_configuration
|
|
192
|
+
FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
193
|
+
DROP POLICY IF EXISTS system_configuration_admin_insert ON public.system_configuration;
|
|
194
|
+
CREATE POLICY system_configuration_admin_insert ON public.system_configuration
|
|
195
|
+
FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
196
|
+
DROP POLICY IF EXISTS system_configuration_admin_update ON public.system_configuration;
|
|
197
|
+
CREATE POLICY system_configuration_admin_update ON public.system_configuration
|
|
198
|
+
FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
199
|
+
DROP POLICY IF EXISTS system_configuration_admin_delete ON public.system_configuration;
|
|
200
|
+
CREATE POLICY system_configuration_admin_delete ON public.system_configuration
|
|
201
|
+
FOR DELETE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
202
|
+
|
|
203
|
+
-- product_inquiries / message_threads: ADMIN read and update (INSERT / DELETE revoked in 02026)
|
|
204
|
+
DROP POLICY IF EXISTS product_inquiries_admin_read_policy ON public.product_inquiries;
|
|
205
|
+
CREATE POLICY product_inquiries_admin_read_policy ON public.product_inquiries
|
|
206
|
+
FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
207
|
+
DROP POLICY IF EXISTS product_inquiries_admin_update_policy ON public.product_inquiries;
|
|
208
|
+
CREATE POLICY product_inquiries_admin_update_policy ON public.product_inquiries
|
|
209
|
+
FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
210
|
+
|
|
211
|
+
DROP POLICY IF EXISTS message_threads_admin_read_policy ON public.message_threads;
|
|
212
|
+
CREATE POLICY message_threads_admin_read_policy ON public.message_threads
|
|
213
|
+
FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
214
|
+
DROP POLICY IF EXISTS message_threads_admin_update_policy ON public.message_threads;
|
|
215
|
+
CREATE POLICY message_threads_admin_update_policy ON public.message_threads
|
|
216
|
+
FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
217
|
+
|
|
218
|
+
-- thread_messages: ADMIN read (every write is the service role's, 02026)
|
|
219
|
+
DROP POLICY IF EXISTS thread_messages_admin_read_policy ON public.thread_messages;
|
|
220
|
+
CREATE POLICY thread_messages_admin_read_policy ON public.thread_messages
|
|
221
|
+
FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
|
|
222
|
+
|
|
223
|
+
-- ---------------------------------------------------------------------------
|
|
224
|
+
-- 4. media: pin uploader_id on insert; which storage keys a row still references
|
|
225
|
+
-- ---------------------------------------------------------------------------
|
|
226
|
+
-- Same shape as 02022's update/delete policies: a WRITER inserts rows attributed to
|
|
227
|
+
-- themselves, an ADMIN may attribute a row to anyone (imports, restores).
|
|
228
|
+
DROP POLICY IF EXISTS media_insert_policy ON public.media;
|
|
229
|
+
CREATE POLICY media_insert_policy ON public.media
|
|
230
|
+
FOR INSERT TO authenticated
|
|
231
|
+
WITH CHECK (
|
|
232
|
+
(SELECT public.is_admin()) IS TRUE
|
|
233
|
+
OR (uploader_id = (SELECT auth.uid())
|
|
234
|
+
AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
-- The keys of p_keys that some media row other than p_exclude_id references as its object_key,
|
|
238
|
+
-- its file_path or a variants[].objectKey. The CMS delete and move actions call it (service
|
|
239
|
+
-- role) after removing or claiming their own rows, and keep every key it returns.
|
|
240
|
+
CREATE OR REPLACE FUNCTION public.media_storage_keys_in_use(p_keys text[], p_exclude_id uuid DEFAULT NULL)
|
|
241
|
+
RETURNS SETOF text
|
|
242
|
+
LANGUAGE sql STABLE SECURITY INVOKER
|
|
243
|
+
SET search_path TO ''
|
|
244
|
+
AS $$
|
|
245
|
+
SELECT DISTINCT k.key
|
|
246
|
+
FROM unnest(p_keys) AS k(key)
|
|
247
|
+
WHERE EXISTS (
|
|
248
|
+
SELECT 1
|
|
249
|
+
FROM public.media m
|
|
250
|
+
WHERE m.id IS DISTINCT FROM p_exclude_id
|
|
251
|
+
AND (m.object_key = k.key
|
|
252
|
+
OR m.file_path = k.key
|
|
253
|
+
OR m.variants @> jsonb_build_array(jsonb_build_object('objectKey', k.key)))
|
|
254
|
+
);
|
|
255
|
+
$$;
|
|
256
|
+
REVOKE ALL ON FUNCTION public.media_storage_keys_in_use(text[], uuid) FROM PUBLIC, anon, authenticated;
|
|
257
|
+
GRANT EXECUTE ON FUNCTION public.media_storage_keys_in_use(text[], uuid) TO service_role;
|
|
258
|
+
|
|
259
|
+
-- ---------------------------------------------------------------------------
|
|
260
|
+
-- 5. package_activations: no license_key / meta for authenticated
|
|
261
|
+
-- ---------------------------------------------------------------------------
|
|
262
|
+
-- Table-level SELECT goes (revoking it also drops any column grant), then SELECT comes back on
|
|
263
|
+
-- every column except the two secrets. The column list is read from the catalog so the
|
|
264
|
+
-- statement matches API_ROLE_REVOKE_SQL; a new column is readable by an aal2 ADMIN, add it to
|
|
265
|
+
-- the exclusions there and here if it is sensitive.
|
|
266
|
+
DO $nb_package_activations$
|
|
267
|
+
DECLARE
|
|
268
|
+
v_cols text;
|
|
269
|
+
BEGIN
|
|
270
|
+
SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY a.attnum)
|
|
271
|
+
INTO v_cols
|
|
272
|
+
FROM pg_attribute a
|
|
273
|
+
WHERE a.attrelid = 'public.package_activations'::regclass AND a.attnum > 0 AND NOT a.attisdropped
|
|
274
|
+
AND a.attname NOT IN ('license_key', 'meta');
|
|
275
|
+
REVOKE SELECT ON TABLE public.package_activations FROM authenticated;
|
|
276
|
+
IF v_cols IS NOT NULL THEN
|
|
277
|
+
EXECUTE format('GRANT SELECT (%s) ON TABLE public.package_activations TO authenticated', v_cols);
|
|
278
|
+
END IF;
|
|
279
|
+
END
|
|
280
|
+
$nb_package_activations$;
|
|
281
|
+
|
|
282
|
+
-- ---------------------------------------------------------------------------
|
|
283
|
+
-- 6. media_read_policy: unchanged on purpose (see the header)
|
|
284
|
+
-- ---------------------------------------------------------------------------
|