MP-301d · Module 1
Table Allowlists & Access Boundaries
4 min read
A read-only database user is necessary but not sufficient. Even with SELECT-only permissions, the AI model can read from any table the user has access to — including system catalogs, audit logs, and tables containing sensitive data like passwords or tokens. Table allowlists add a second layer: the MCP server maintains a hardcoded list of tables it will query, and rejects any request targeting an unlisted table. This defense-in-depth approach means that even if the database role has broader permissions than intended, the MCP server constrains the exposure.
Allowlists should be explicit, not pattern-based. A glob like "public.*" technically works but defeats the purpose — it exposes every table in the public schema, including any you add later. Enumerate every table by name: ["customers", "orders", "products", "order_items"]. When you add a new table to the database, you must explicitly add it to the allowlist before the AI can see it. This friction is intentional — it forces a security review for every new data exposure.
Column-level allowlists provide even finer control. Instead of exposing all columns in a table, define which columns the AI can read. A customers table might expose id, name, company, and status but not ssn, password_hash, or internal_notes. Column allowlists are specified per table and enforced by constructing SELECT statements with explicit column lists instead of SELECT *. This prevents accidental exposure when new sensitive columns are added to existing tables.
// Table + column allowlist configuration
interface TableAccess {
table: string;
columns: string[]; // Explicit column list — no SELECT *
maxRows: number; // Per-query row limit
description: string; // Why this table is exposed
}
const ALLOWED_TABLES: TableAccess[] = [
{
table: "customers",
columns: ["id", "name", "company", "status", "created_at"],
maxRows: 500,
description: "Customer lookup — excludes PII (ssn, email, phone)",
},
{
table: "orders",
columns: ["id", "customer_id", "status", "total", "created_at"],
maxRows: 1000,
description: "Order history — excludes payment details",
},
{
table: "products",
columns: ["id", "name", "sku", "category", "price", "active"],
maxRows: 2000,
description: "Product catalog — full read access",
},
];
function buildSelectQuery(tableName: string, whereClause?: string): string {
const access = ALLOWED_TABLES.find((t) => t.table === tableName);
if (!access) throw new Error(`Table "${tableName}" is not exposed`);
const cols = access.columns.map((c) => `"${c}"`).join(", ");
let sql = `SELECT ${cols} FROM "${access.table}"`;
if (whereClause) sql += ` WHERE ${whereClause}`;
sql += ` LIMIT ${access.maxRows}`;
return sql;
}
Do This
- Enumerate every table by name in a hardcoded allowlist
- Specify allowed columns per table — never use SELECT *
- Set per-table row limits to prevent context window overflow
- Document why each table is exposed and who approved it
Avoid This
- Use glob patterns or schema-wide access — you expose tables added in the future
- Trust the database role as the sole access control — it is too coarse
- Allow system catalogs (information_schema, pg_catalog) — they leak schema details
- Hardcode allowlists in query functions — centralize them in a config object