Skip to content

Commit 460bc21

Browse files
authored
Allowlist & RLS Application Layer
Allowlist & RLS Application Layer
2 parents fa0209e + c35c3d0 commit 460bc21

File tree

13 files changed

+625
-66
lines changed

13 files changed

+625
-66
lines changed

install.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ echo "Cloning the repository..."
4040
git clone https://github.com/outerbase/starbasedb.git > /dev/null 2>&1 || { echo "Error: Failed to clone the repository. Please check your internet connection and try again."; exit 1; }
4141
cd starbasedb || { echo "Error: Failed to change to the starbasedb directory. The clone might have failed."; exit 1; }
4242

43-
# Step 3: Generate a secure AUTHORIZATION_TOKEN and update wrangler.toml
43+
# Step 3: Generate a secure ADMIN_AUTHORIZATION_TOKEN and update wrangler.toml
4444
os=$(uname -s)
4545
PLATFORM_SED="sed -i ''"
4646

@@ -64,8 +64,8 @@ case "$os" in
6464
;;
6565
esac
6666

67-
AUTHORIZATION_TOKEN=$(openssl rand -hex 16)
68-
$PLATFORM_SED "s/AUTHORIZATION_TOKEN = \"[^\"]*\"/AUTHORIZATION_TOKEN = \"$AUTHORIZATION_TOKEN\"/" wrangler.toml
67+
ADMIN_AUTHORIZATION_TOKEN=$(openssl rand -hex 16)
68+
$PLATFORM_SED "s/ADMIN_AUTHORIZATION_TOKEN = \"[^\"]*\"/ADMIN_AUTHORIZATION_TOKEN = \"$ADMIN_AUTHORIZATION_TOKEN\"/" wrangler.toml
6969

7070
# Step 4: Prompt the user for Cloudflare account_id (force interaction)
7171
echo " "

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
"dependencies": {
1818
"@libsql/client": "^0.14.0",
1919
"@outerbase/sdk": "2.0.0-rc.3",
20+
"jose": "^5.9.6",
2021
"mongodb": "^6.11.0",
2122
"mysql2": "^3.11.4",
22-
"pg": "^8.13.1",
23-
"node-sql-parser": "^4.18.0"
23+
"node-sql-parser": "^4.18.0",
24+
"pg": "^8.13.1"
2425
}
2526
}

pnpm-lock.yaml

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/allowlist/index.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Env } from "..";
2+
import { DataSource } from "../types";
3+
4+
const parser = new (require('node-sql-parser').Parser)();
5+
6+
let allowlist: string[] | null = null;
7+
let normalizedAllowlist: any[] | null = null;
8+
9+
function normalizeSQL(sql: string) {
10+
// Remove trailing semicolon. This allows a user to send a SQL statement that has
11+
// a semicolon where the allow list might not include it but both statements can
12+
// equate to being the same. AST seems to have an issue with matching the difference
13+
// when included in one query vs another.
14+
return sql.trim().replace(/;\s*$/, '');
15+
}
16+
17+
async function loadAllowlist(dataSource?: DataSource): Promise<string[]> {
18+
try {
19+
const statement = 'SELECT sql_statement FROM tmp_allowlist_queries'
20+
const result = await dataSource?.internalConnection?.durableObject.executeQuery(statement, [], false) as any[];
21+
return result.map((row: any) => row.sql_statement);
22+
} catch (error) {
23+
console.error('Error loading allowlist:', error);
24+
return [];
25+
}
26+
}
27+
28+
export async function isQueryAllowed(sql: string, isEnabled: boolean, dataSource?: DataSource, env?: Env): Promise<boolean | Error> {
29+
// If the feature is not turned on then by default the query is allowed
30+
if (!isEnabled) return true;
31+
32+
// If we are using the administrative AUTHORIZATION token value, this request is allowed.
33+
// We want database UI's to be able to have more free reign to run queries so we can load
34+
// tables, run queries, and more. If you want to block queries with the allowlist then we
35+
// advise you to do so by implementing user authentication with JWT.
36+
if (dataSource?.request.headers.get('Authorization') === `Bearer ${env?.ADMIN_AUTHORIZATION_TOKEN}`) {
37+
return true;
38+
}
39+
40+
allowlist = await loadAllowlist(dataSource);
41+
normalizedAllowlist = allowlist.map(query => parser.astify(normalizeSQL(query)));
42+
43+
try {
44+
if (!sql) {
45+
return Error('No SQL provided for allowlist check')
46+
}
47+
48+
const normalizedQuery = parser.astify(normalizeSQL(sql));
49+
50+
// Compare ASTs while ignoring specific values
51+
const isCurrentAllowed = normalizedAllowlist?.some(allowedQuery => {
52+
// Create deep copies to avoid modifying original ASTs
53+
const allowedAst = JSON.parse(JSON.stringify(allowedQuery));
54+
const queryAst = JSON.parse(JSON.stringify(normalizedQuery));
55+
56+
// Remove or normalize value fields from both ASTs
57+
const normalizeAst = (ast: any) => {
58+
if (Array.isArray(ast)) {
59+
ast.forEach(normalizeAst);
60+
} else if (ast && typeof ast === 'object') {
61+
// Remove or normalize fields that contain specific values
62+
if ('value' in ast) {
63+
ast.value = '?';
64+
}
65+
66+
Object.values(ast).forEach(normalizeAst);
67+
}
68+
69+
return ast;
70+
};
71+
72+
normalizeAst(allowedAst);
73+
normalizeAst(queryAst);
74+
75+
return JSON.stringify(allowedAst) === JSON.stringify(queryAst);
76+
});
77+
78+
if (!isCurrentAllowed) {
79+
throw new Error("Query not allowed");
80+
}
81+
82+
return true;
83+
} catch (error: any) {
84+
throw new Error(error?.message ?? 'Error');
85+
}
86+
}

src/cache/index.ts

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,6 @@
11
import { DataSource, Source } from "../types";
22
const parser = new (require('node-sql-parser').Parser)();
33

4-
async function createCacheTable(dataSource?: DataSource) {
5-
const statement = `
6-
CREATE TABLE IF NOT EXISTS "main"."tmp_cache"(
7-
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
8-
"timestamp" REAL NOT NULL,
9-
"ttl" INTEGER NOT NULL,
10-
"query" TEXT UNIQUE NOT NULL,
11-
"results" TEXT
12-
);
13-
`
14-
15-
await dataSource?.internalConnection?.durableObject.executeQuery(statement, undefined, false)
16-
}
17-
184
function hasModifyingStatement(ast: any): boolean {
195
// Check if current node is a modifying statement
206
if (ast.type && ['insert', 'update', 'delete'].includes(ast.type.toLowerCase())) {
@@ -33,28 +19,30 @@ function hasModifyingStatement(ast: any): boolean {
3319
}
3420
}
3521
}
36-
22+
3723
return false;
3824
}
3925

40-
export async function beforeQueryCache(sql: string, params?: any[], dataSource?: DataSource): Promise<any | null> {
26+
export async function beforeQueryCache(sql: string, params?: any[], dataSource?: DataSource, dialect?: string): Promise<any | null> {
4127
// Currently we do not support caching queries that have dynamic parameters
4228
if (params?.length) return null
29+
if (dataSource?.source === Source.internal || !dataSource?.request.headers.has('X-Starbase-Cache')) return null
4330

44-
let ast = parser.astify(sql);
31+
if (!dialect) dialect = 'sqlite'
32+
if (dialect.toLowerCase() === 'postgres') dialect = 'postgresql'
4533

46-
if (!hasModifyingStatement(ast) && dataSource?.source === Source.external && dataSource?.request.headers.has('X-Starbase-Cache')) {
47-
await createCacheTable(dataSource)
48-
const fetchCacheStatement = `SELECT timestamp, ttl, query, results FROM tmp_cache WHERE query = ?`
49-
const result = await dataSource.internalConnection?.durableObject.executeQuery(fetchCacheStatement, [sql], false) as any[];
34+
let ast = parser.astify(sql, { database: dialect });
35+
if (hasModifyingStatement(ast)) return null
36+
37+
const fetchCacheStatement = `SELECT timestamp, ttl, query, results FROM tmp_cache WHERE query = ?`
38+
const result = await dataSource.internalConnection?.durableObject.executeQuery(fetchCacheStatement, [sql], false) as any[];
5039

51-
if (result?.length) {
52-
const { timestamp, ttl, results } = result[0];
53-
const expirationTime = new Date(timestamp).getTime() + (ttl * 1000);
54-
55-
if (Date.now() < expirationTime) {
56-
return JSON.parse(results)
57-
}
40+
if (result?.length) {
41+
const { timestamp, ttl, results } = result[0];
42+
const expirationTime = new Date(timestamp).getTime() + (ttl * 1000);
43+
44+
if (Date.now() < expirationTime) {
45+
return JSON.parse(results)
5846
}
5947
}
6048

@@ -67,16 +55,19 @@ export async function beforeQueryCache(sql: string, params?: any[], dataSource?:
6755
// to look into include using Cloudflare Cache but need to find a good way to cache the
6856
// response in a safe way for our use case. Another option is another service for queues
6957
// or another way to ingest it directly to the Durable Object.
70-
export async function afterQueryCache(sql: string, params: any[] | undefined, result: any, dataSource?: DataSource) {
58+
export async function afterQueryCache(sql: string, params: any[] | undefined, result: any, dataSource?: DataSource, dialect?: string) {
7159
// Currently we do not support caching queries that have dynamic parameters
7260
if (params?.length) return;
61+
if (dataSource?.source === Source.internal || !dataSource?.request.headers.has('X-Starbase-Cache')) return null
7362

7463
try {
75-
let ast = parser.astify(sql);
64+
if (!dialect) dialect = 'sqlite'
65+
if (dialect.toLowerCase() === 'postgres') dialect = 'postgresql'
66+
67+
let ast = parser.astify(sql, { database: dialect });
7668

7769
// If any modifying query exists within our SQL statement then we shouldn't proceed
78-
if (hasModifyingStatement(ast) ||
79-
!(dataSource?.source === Source.external && dataSource?.request.headers.has('X-Starbase-Cache'))) return;
70+
if (hasModifyingStatement(ast)) return;
8071

8172
const timestamp = Date.now();
8273
const results = JSON.stringify(result);

src/do.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,38 @@ export class DatabaseDurableObject extends DurableObject {
2424
super(ctx, env);
2525
this.sql = ctx.storage.sql;
2626
this.storage = ctx.storage;
27+
28+
// Install default necessary `tmp_` tables for various features here.
29+
const cacheStatement = `
30+
CREATE TABLE IF NOT EXISTS tmp_cache (
31+
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
32+
"timestamp" REAL NOT NULL,
33+
"ttl" INTEGER NOT NULL,
34+
"query" TEXT UNIQUE NOT NULL,
35+
"results" TEXT
36+
);`
37+
38+
const allowlistStatement = `
39+
CREATE TABLE IF NOT EXISTS tmp_allowlist_queries (
40+
id INTEGER PRIMARY KEY AUTOINCREMENT,
41+
sql_statement TEXT NOT NULL
42+
)`
43+
44+
const rlsStatement = `
45+
CREATE TABLE IF NOT EXISTS tmp_rls_policies (
46+
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
47+
"actions" TEXT NOT NULL CHECK(actions IN ('SELECT', 'UPDATE', 'INSERT', 'DELETE')),
48+
"schema" TEXT,
49+
"table" TEXT NOT NULL,
50+
"column" TEXT NOT NULL,
51+
"value" TEXT NOT NULL,
52+
"value_type" TEXT NOT NULL DEFAULT 'string',
53+
"operator" TEXT DEFAULT '='
54+
)`
55+
56+
this.executeQuery(cacheStatement, undefined, false)
57+
this.executeQuery(allowlistStatement, undefined, false)
58+
this.executeQuery(rlsStatement, undefined, false)
2759
}
2860

2961
/**

src/handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export class Handler {
9999
}
100100

101101
const { sql, params, transaction } = await request.json() as QueryRequest & QueryTransactionRequest;
102-
102+
103103
if (Array.isArray(transaction) && transaction.length) {
104104
const queries = transaction.map((queryObj: any) => {
105105
const { sql, params } = queryObj;
@@ -125,7 +125,7 @@ export class Handler {
125125
return createResponse(response, undefined, 200);
126126
} catch (error: any) {
127127
console.error('Query Route Error:', error);
128-
return createResponse(undefined, error || 'An unexpected error occurred.', 500);
128+
return createResponse(undefined, error?.message || 'An unexpected error occurred.', 500);
129129
}
130130
}
131131

0 commit comments

Comments
 (0)