@sigma4life/mysql-mcp-server 1.0.0

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.
Files changed (149) hide show
  1. package/.env.example +35 -0
  2. package/.github/workflows/ci.yml +18 -0
  3. package/AUTHENTICATION.md +24 -0
  4. package/CLAUDE.md +37 -0
  5. package/CONTRIBUTING.md +19 -0
  6. package/LICENSE +21 -0
  7. package/QUERY_ACCESS_SETUP.md +65 -0
  8. package/README.md +144 -0
  9. package/SECURITY.md +10 -0
  10. package/TESTING.md +54 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +197 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/core/cache.d.ts +11 -0
  16. package/dist/core/cache.d.ts.map +1 -0
  17. package/dist/core/cache.js +30 -0
  18. package/dist/core/cache.js.map +1 -0
  19. package/dist/core/config.d.ts +24 -0
  20. package/dist/core/config.d.ts.map +1 -0
  21. package/dist/core/config.js +63 -0
  22. package/dist/core/config.js.map +1 -0
  23. package/dist/core/database-url.d.ts +11 -0
  24. package/dist/core/database-url.d.ts.map +1 -0
  25. package/dist/core/database-url.js +37 -0
  26. package/dist/core/database-url.js.map +1 -0
  27. package/dist/core/database-url.test.d.ts +2 -0
  28. package/dist/core/database-url.test.d.ts.map +1 -0
  29. package/dist/core/database-url.test.js +22 -0
  30. package/dist/core/database-url.test.js.map +1 -0
  31. package/dist/core/logger.d.ts +3 -0
  32. package/dist/core/logger.d.ts.map +1 -0
  33. package/dist/core/logger.js +17 -0
  34. package/dist/core/logger.js.map +1 -0
  35. package/dist/core/mcp.d.ts +14 -0
  36. package/dist/core/mcp.d.ts.map +1 -0
  37. package/dist/core/mcp.js +23 -0
  38. package/dist/core/mcp.js.map +1 -0
  39. package/dist/core/query-safety.d.ts +10 -0
  40. package/dist/core/query-safety.d.ts.map +1 -0
  41. package/dist/core/query-safety.js +50 -0
  42. package/dist/core/query-safety.js.map +1 -0
  43. package/dist/core/query-safety.test.d.ts +2 -0
  44. package/dist/core/query-safety.test.d.ts.map +1 -0
  45. package/dist/core/query-safety.test.js +30 -0
  46. package/dist/core/query-safety.test.js.map +1 -0
  47. package/dist/core/schema-types.d.ts +53 -0
  48. package/dist/core/schema-types.d.ts.map +1 -0
  49. package/dist/core/schema-types.js +2 -0
  50. package/dist/core/schema-types.js.map +1 -0
  51. package/dist/core/security/access-control.d.ts +32 -0
  52. package/dist/core/security/access-control.d.ts.map +1 -0
  53. package/dist/core/security/access-control.js +244 -0
  54. package/dist/core/security/access-control.js.map +1 -0
  55. package/dist/core/security/config-loader.d.ts +20 -0
  56. package/dist/core/security/config-loader.d.ts.map +1 -0
  57. package/dist/core/security/config-loader.js +227 -0
  58. package/dist/core/security/config-loader.js.map +1 -0
  59. package/dist/core/security/types.d.ts +64 -0
  60. package/dist/core/security/types.d.ts.map +1 -0
  61. package/dist/core/security/types.js +28 -0
  62. package/dist/core/security/types.js.map +1 -0
  63. package/dist/core/sql-parser.d.ts +23 -0
  64. package/dist/core/sql-parser.d.ts.map +1 -0
  65. package/dist/core/sql-parser.js +460 -0
  66. package/dist/core/sql-parser.js.map +1 -0
  67. package/dist/core/sql-parser.test.d.ts +2 -0
  68. package/dist/core/sql-parser.test.d.ts.map +1 -0
  69. package/dist/core/sql-parser.test.js +21 -0
  70. package/dist/core/sql-parser.test.js.map +1 -0
  71. package/dist/handlers/accessible-schema.d.ts +53 -0
  72. package/dist/handlers/accessible-schema.d.ts.map +1 -0
  73. package/dist/handlers/accessible-schema.js +192 -0
  74. package/dist/handlers/accessible-schema.js.map +1 -0
  75. package/dist/handlers/data.d.ts +17 -0
  76. package/dist/handlers/data.d.ts.map +1 -0
  77. package/dist/handlers/data.js +30 -0
  78. package/dist/handlers/data.js.map +1 -0
  79. package/dist/handlers/relationships.d.ts +14 -0
  80. package/dist/handlers/relationships.d.ts.map +1 -0
  81. package/dist/handlers/relationships.js +77 -0
  82. package/dist/handlers/relationships.js.map +1 -0
  83. package/dist/handlers/schema.d.ts +14 -0
  84. package/dist/handlers/schema.d.ts.map +1 -0
  85. package/dist/handlers/schema.js +104 -0
  86. package/dist/handlers/schema.js.map +1 -0
  87. package/dist/handlers/search.d.ts +14 -0
  88. package/dist/handlers/search.d.ts.map +1 -0
  89. package/dist/handlers/search.js +18 -0
  90. package/dist/handlers/search.js.map +1 -0
  91. package/dist/handlers/validation.d.ts +32 -0
  92. package/dist/handlers/validation.d.ts.map +1 -0
  93. package/dist/handlers/validation.js +116 -0
  94. package/dist/handlers/validation.js.map +1 -0
  95. package/dist/index.d.ts +3 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +294 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/mysql/connection.d.ts +15 -0
  100. package/dist/mysql/connection.d.ts.map +1 -0
  101. package/dist/mysql/connection.js +57 -0
  102. package/dist/mysql/connection.js.map +1 -0
  103. package/dist/mysql/identifiers.d.ts +3 -0
  104. package/dist/mysql/identifiers.d.ts.map +1 -0
  105. package/dist/mysql/identifiers.js +10 -0
  106. package/dist/mysql/identifiers.js.map +1 -0
  107. package/dist/mysql/identifiers.test.d.ts +2 -0
  108. package/dist/mysql/identifiers.test.d.ts.map +1 -0
  109. package/dist/mysql/identifiers.test.js +11 -0
  110. package/dist/mysql/identifiers.test.js.map +1 -0
  111. package/dist/mysql/queries.d.ts +49 -0
  112. package/dist/mysql/queries.d.ts.map +1 -0
  113. package/dist/mysql/queries.js +206 -0
  114. package/dist/mysql/queries.js.map +1 -0
  115. package/docs/clients/claude-code.md +28 -0
  116. package/docs/clients/claude-desktop.md +35 -0
  117. package/docs/clients/codex.md +28 -0
  118. package/docs/clients/cursor.md +26 -0
  119. package/docs/clients/opencode.md +35 -0
  120. package/docs/clients/vscode.md +25 -0
  121. package/package.json +49 -0
  122. package/query-access.example.json +21 -0
  123. package/src/cli.ts +221 -0
  124. package/src/core/cache.ts +41 -0
  125. package/src/core/config.ts +97 -0
  126. package/src/core/database-url.test.ts +28 -0
  127. package/src/core/database-url.ts +47 -0
  128. package/src/core/logger.ts +24 -0
  129. package/src/core/mcp.ts +23 -0
  130. package/src/core/query-safety.test.ts +36 -0
  131. package/src/core/query-safety.ts +63 -0
  132. package/src/core/schema-types.ts +58 -0
  133. package/src/core/security/access-control.ts +321 -0
  134. package/src/core/security/config-loader.ts +315 -0
  135. package/src/core/security/types.ts +114 -0
  136. package/src/core/sql-parser.test.ts +37 -0
  137. package/src/core/sql-parser.ts +572 -0
  138. package/src/handlers/accessible-schema.ts +314 -0
  139. package/src/handlers/data.ts +66 -0
  140. package/src/handlers/relationships.ts +114 -0
  141. package/src/handlers/schema.ts +154 -0
  142. package/src/handlers/search.ts +34 -0
  143. package/src/handlers/validation.ts +165 -0
  144. package/src/index.ts +337 -0
  145. package/src/mysql/connection.ts +68 -0
  146. package/src/mysql/identifiers.test.ts +12 -0
  147. package/src/mysql/identifiers.ts +10 -0
  148. package/src/mysql/queries.ts +285 -0
  149. package/tsconfig.json +24 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Access Control Validation for execute_query
3
+ *
4
+ * Validates SQL queries against access control configuration:
5
+ * - Database access (must be configured)
6
+ * - Table whitelist/blacklist
7
+ * - Column access (inclusion/exclusion modes)
8
+ * - SELECT * blocking
9
+ */
10
+
11
+ import {
12
+ AccessControlConfig,
13
+ AccessViolation,
14
+ AccessControlError,
15
+ QualifiedTableRef,
16
+ QualifiedColumnRef,
17
+ } from "./types.js";
18
+ import { getTableConfigForSchema } from "./config-loader.js";
19
+ import { parseQuery } from "../sql-parser.js";
20
+ import { logger } from "../logger.js";
21
+
22
+ /**
23
+ * Validate a query against access control configuration
24
+ * @throws AccessControlError if validation fails
25
+ */
26
+ export function validateQueryAccess(
27
+ query: string,
28
+ database: string,
29
+ config: AccessControlConfig,
30
+ ): void {
31
+ const violations: AccessViolation[] = [];
32
+
33
+ // Step 1: Check if database is configured
34
+ const dbViolation = validateDatabaseAccess(database, config);
35
+ if (dbViolation) {
36
+ throw new AccessControlError([dbViolation]);
37
+ }
38
+
39
+ // Step 2: Parse the query
40
+ const parsed = parseQuery(query, database);
41
+ logger.debug(
42
+ `Parsed query info: tables=${parsed.tables.length}, columns=${parsed.columns.length}, hasSelectStar=${parsed.hasSelectStar}`,
43
+ );
44
+
45
+ // Step 3: Validate SELECT * usage
46
+ if (config.requireExplicitColumns && parsed.hasSelectStar) {
47
+ violations.push(...validateSelectStar(parsed.selectStarTables));
48
+ }
49
+
50
+ // Step 4: Validate table access
51
+ for (const table of parsed.tables) {
52
+ const tableViolation = validateTableAccess(table, config);
53
+ if (tableViolation) {
54
+ violations.push(tableViolation);
55
+ }
56
+ }
57
+
58
+ // Step 5: Validate column access (only if no SELECT *)
59
+ // If SELECT * is used, we can't know which columns will be returned
60
+ // so we already blocked it above
61
+ if (!parsed.hasSelectStar) {
62
+ for (const column of parsed.columns) {
63
+ const columnViolation = validateColumnAccess(column, config);
64
+ if (columnViolation) {
65
+ violations.push(columnViolation);
66
+ }
67
+ }
68
+ }
69
+
70
+ // Throw if any violations found
71
+ if (violations.length > 0) {
72
+ logger.warn(
73
+ `Access control violations: ${violations.map((v) => v.message).join("; ")}`,
74
+ );
75
+ throw new AccessControlError(violations);
76
+ }
77
+
78
+ logger.debug("Query passed access control validation");
79
+ }
80
+
81
+ /**
82
+ * Validate database is configured
83
+ */
84
+ function validateDatabaseAccess(
85
+ database: string,
86
+ config: AccessControlConfig,
87
+ ): AccessViolation | null {
88
+ const dbUpper = database.toUpperCase();
89
+ if (!config.databases[dbUpper]) {
90
+ return {
91
+ type: "database_not_configured",
92
+ database,
93
+ message:
94
+ `Database '${database}' is not configured for query access. ` +
95
+ `Add it to QUERY_ACCESS_CONFIG to enable queries.`,
96
+ };
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Validate SELECT * usage
103
+ */
104
+ function validateSelectStar(selectStarTables: string[]): AccessViolation[] {
105
+ const violations: AccessViolation[] = [];
106
+
107
+ for (const tableRef of selectStarTables) {
108
+ if (tableRef === "*") {
109
+ violations.push({
110
+ type: "select_star",
111
+ message:
112
+ "SELECT * is not allowed. All SELECT statements must explicitly list columns. " +
113
+ "Example: SELECT name, email FROM customers",
114
+ });
115
+ } else {
116
+ violations.push({
117
+ type: "select_star",
118
+ table: tableRef,
119
+ message:
120
+ `SELECT ${tableRef}.* is not allowed. ` +
121
+ `Please specify columns explicitly instead of using table.* syntax.`,
122
+ });
123
+ }
124
+ }
125
+
126
+ return violations;
127
+ }
128
+
129
+ /**
130
+ * Validate table access against whitelist/blacklist
131
+ */
132
+ function validateTableAccess(
133
+ table: QualifiedTableRef,
134
+ config: AccessControlConfig,
135
+ ): AccessViolation | null {
136
+ // Skip subquery pseudo-tables
137
+ if (table.schema === "__subquery__") {
138
+ return null;
139
+ }
140
+
141
+ // Skip CTE references (they're query-scoped aliases, not real tables)
142
+ if (table.schema === "__cte__") {
143
+ return null;
144
+ }
145
+
146
+ const schemaConfig = getTableConfigForSchema(
147
+ config,
148
+ table.database,
149
+ table.schema,
150
+ );
151
+
152
+ if (!schemaConfig) {
153
+ return {
154
+ type: "schema_not_configured",
155
+ database: table.database,
156
+ schema: table.schema,
157
+ table: table.table,
158
+ message:
159
+ `Schema '${table.schema}' in database '${table.database}' is not configured for query access. ` +
160
+ `Add schema rules to QUERY_ACCESS_CONFIG.`,
161
+ };
162
+ }
163
+
164
+ const { tableConfig } = schemaConfig;
165
+ const tableNameLower = table.table.toLowerCase();
166
+
167
+ // Check whitelist/blacklist
168
+ const listLower = tableConfig.list.map((t) => t.toLowerCase());
169
+
170
+ switch (tableConfig.mode) {
171
+ case "whitelist":
172
+ if (!listLower.includes(tableNameLower)) {
173
+ return {
174
+ type: "table_not_allowed",
175
+ database: table.database,
176
+ schema: table.schema,
177
+ table: table.table,
178
+ message:
179
+ `Table '${table.database}.${table.schema}.${table.table}' is not in the allowed tables list. ` +
180
+ `Allowed tables for ${table.database}.${table.schema}: ${tableConfig.list.join(", ") || "(none)"}`,
181
+ };
182
+ }
183
+ break;
184
+
185
+ case "blacklist":
186
+ if (listLower.includes(tableNameLower)) {
187
+ return {
188
+ type: "table_not_allowed",
189
+ database: table.database,
190
+ schema: table.schema,
191
+ table: table.table,
192
+ message:
193
+ `Table '${table.database}.${table.schema}.${table.table}' cannot be queried. ` +
194
+ `This table is in the exclusion list for database '${table.database}', schema '${table.schema}'.`,
195
+ };
196
+ }
197
+ break;
198
+
199
+ case "none":
200
+ // No table-level restrictions
201
+ break;
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Validate column access against inclusion/exclusion rules
209
+ */
210
+ function validateColumnAccess(
211
+ column: QualifiedColumnRef,
212
+ config: AccessControlConfig,
213
+ ): AccessViolation | null {
214
+ // Skip unknown table columns (can't validate)
215
+ if (column.table === "__unknown__") {
216
+ return null;
217
+ }
218
+
219
+ const schemaConfig = getTableConfigForSchema(
220
+ config,
221
+ column.database,
222
+ column.schema,
223
+ );
224
+ if (!schemaConfig) {
225
+ // Schema not configured - already caught in table validation
226
+ return null;
227
+ }
228
+
229
+ const { columnAccess } = schemaConfig;
230
+ const tableNameLower = column.table.toLowerCase();
231
+ const columnNameLower = column.column.toLowerCase();
232
+
233
+ // Find policy for this table (case-insensitive)
234
+ let policy = null;
235
+ let policyTableName = "";
236
+ for (const [table, tablePolicy] of Object.entries(columnAccess)) {
237
+ if (table.toLowerCase() === tableNameLower) {
238
+ policy = tablePolicy;
239
+ policyTableName = table;
240
+ break;
241
+ }
242
+ }
243
+
244
+ // No policy = allow all columns
245
+ if (!policy) {
246
+ return null;
247
+ }
248
+
249
+ const columnsLower = policy.columns.map((c) => c.toLowerCase());
250
+
251
+ if (policy.mode === "inclusion") {
252
+ // Whitelist: column must be in the list
253
+ if (!columnsLower.includes(columnNameLower)) {
254
+ return {
255
+ type: "column_not_allowed",
256
+ database: column.database,
257
+ schema: column.schema,
258
+ table: column.table,
259
+ column: column.column,
260
+ message:
261
+ `Column '${column.column}' from '${column.database}.${column.schema}.${column.table}' cannot be selected. ` +
262
+ `Allowed columns for ${policyTableName}: ${policy.columns.join(", ")}`,
263
+ };
264
+ }
265
+ } else {
266
+ // Blacklist: column must NOT be in the list
267
+ if (columnsLower.includes(columnNameLower)) {
268
+ return {
269
+ type: "column_excluded",
270
+ database: column.database,
271
+ schema: column.schema,
272
+ table: column.table,
273
+ column: column.column,
274
+ message:
275
+ `Column '${column.column}' from '${column.database}.${column.schema}.${column.table}' cannot be selected. ` +
276
+ `Excluded columns: ${policy.columns.join(", ")}`,
277
+ };
278
+ }
279
+ }
280
+
281
+ return null;
282
+ }
283
+
284
+ // Singleton config holder
285
+ let globalConfig: AccessControlConfig | null = null;
286
+
287
+ /**
288
+ * Initialize the global access control config
289
+ * Called once at startup from index.ts
290
+ */
291
+ export function initAccessControl(config: AccessControlConfig): void {
292
+ globalConfig = config;
293
+ logger.info("Access control initialized");
294
+ }
295
+
296
+ /**
297
+ * Get the global access control config
298
+ * @throws Error if not initialized
299
+ */
300
+ export function getAccessControlConfig(): AccessControlConfig {
301
+ if (!globalConfig) {
302
+ throw new Error(
303
+ "Access control not initialized. Ensure QUERY_ACCESS_CONFIG is set and valid.",
304
+ );
305
+ }
306
+ return globalConfig;
307
+ }
308
+
309
+ /**
310
+ * Check if access control is initialized
311
+ */
312
+ export function isAccessControlInitialized(): boolean {
313
+ return globalConfig !== null;
314
+ }
315
+
316
+ // Re-export types for convenience
317
+ export { AccessControlConfig, AccessControlError } from "./types.js";
318
+ export {
319
+ loadAccessControlConfig,
320
+ getTableConfigForSchema,
321
+ } from "./config-loader.js";
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Access Control Configuration Loader
3
+ *
4
+ * Loads configuration from JSON file specified by QUERY_ACCESS_CONFIG env var.
5
+ * Validates configuration structure and provides helpful error messages.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import {
11
+ AccessControlConfig,
12
+ DatabaseConfig,
13
+ TableConfig,
14
+ SchemaConfig,
15
+ ColumnAccessPolicy,
16
+ } from './types.js';
17
+ import { logger } from '../logger.js';
18
+
19
+ // Environment variable for config file path
20
+ const CONFIG_ENV_VAR = 'QUERY_ACCESS_CONFIG';
21
+
22
+ /**
23
+ * Load and validate access control configuration
24
+ * @throws Error if config is invalid or missing (restrictive default)
25
+ */
26
+ export function loadAccessControlConfig(): AccessControlConfig {
27
+ const configPath = process.env[CONFIG_ENV_VAR];
28
+
29
+ if (!configPath) {
30
+ throw new Error(
31
+ `Access control configuration not found. ` +
32
+ `Set ${CONFIG_ENV_VAR} environment variable to the path of your config file. ` +
33
+ `Example: ${CONFIG_ENV_VAR}=/path/to/query-access.json`
34
+ );
35
+ }
36
+
37
+ // Resolve path (handle relative paths)
38
+ const resolvedPath = path.resolve(configPath);
39
+
40
+ if (!fs.existsSync(resolvedPath)) {
41
+ throw new Error(
42
+ `Access control config file not found at: ${resolvedPath}. ` +
43
+ `Create the config file or update ${CONFIG_ENV_VAR} to point to the correct location.`
44
+ );
45
+ }
46
+
47
+ logger.info(`Loading access control config from: ${resolvedPath}`);
48
+
49
+ let rawConfig: any;
50
+ try {
51
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
52
+ rawConfig = JSON.parse(content);
53
+ } catch (error: any) {
54
+ throw new Error(`Failed to parse access control config: ${error.message}`);
55
+ }
56
+
57
+ // Validate and normalize the configuration
58
+ const config = validateConfig(rawConfig);
59
+
60
+ logger.info(
61
+ `Access control config loaded: ${Object.keys(config.databases).length} database(s) configured`
62
+ );
63
+
64
+ return config;
65
+ }
66
+
67
+ /**
68
+ * Validate configuration structure
69
+ */
70
+ function validateConfig(raw: any): AccessControlConfig {
71
+ if (typeof raw !== 'object' || raw === null) {
72
+ throw new Error('Access control config must be a JSON object');
73
+ }
74
+
75
+ // Validate requireExplicitColumns
76
+ if (typeof raw.requireExplicitColumns !== 'boolean') {
77
+ throw new Error("Access control config must have 'requireExplicitColumns' (boolean)");
78
+ }
79
+
80
+ // Validate databases
81
+ if (typeof raw.databases !== 'object' || raw.databases === null) {
82
+ throw new Error("Access control config must have 'databases' object");
83
+ }
84
+
85
+ const databases: Record<string, DatabaseConfig> = {};
86
+
87
+ for (const [dbName, dbConfig] of Object.entries(raw.databases)) {
88
+ databases[dbName.toUpperCase()] = validateDatabaseConfig(dbName, dbConfig);
89
+ }
90
+
91
+ return {
92
+ requireExplicitColumns: raw.requireExplicitColumns,
93
+ databases,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Validate database configuration
99
+ */
100
+ function validateDatabaseConfig(dbName: string, raw: any): DatabaseConfig {
101
+ if (typeof raw !== 'object' || raw === null) {
102
+ throw new Error(`Database config for '${dbName}' must be an object`);
103
+ }
104
+
105
+ // Error on deprecated columnExclusions format
106
+ if (raw.columnExclusions) {
107
+ throw new Error(
108
+ `Database '${dbName}' uses deprecated 'columnExclusions' format. ` +
109
+ `Please migrate to 'columnAccess' with { mode: 'exclusion' | 'inclusion', columns: [...] } per table. ` +
110
+ `See documentation for the new format.`
111
+ );
112
+ }
113
+
114
+ const result: DatabaseConfig = {};
115
+
116
+ // Check for schema-level config (full format)
117
+ if (raw.schemas) {
118
+ if (typeof raw.schemas !== 'object') {
119
+ throw new Error(`'schemas' in database '${dbName}' must be an object`);
120
+ }
121
+
122
+ result.schemas = {};
123
+ for (const [schemaName, schemaConfig] of Object.entries(raw.schemas)) {
124
+ result.schemas[schemaName.toLowerCase()] = validateSchemaConfig(
125
+ dbName,
126
+ schemaName,
127
+ schemaConfig
128
+ );
129
+ }
130
+ }
131
+
132
+ // Check for compact format (tables at database level)
133
+ if (raw.tables) {
134
+ result.tables = validateTableConfig(dbName, '_default_', raw.tables);
135
+ }
136
+
137
+ // Must have either schemas or tables
138
+ if (!result.schemas && !result.tables) {
139
+ throw new Error(
140
+ `Database '${dbName}' must have either 'schemas' or 'tables' configuration`
141
+ );
142
+ }
143
+
144
+ return result;
145
+ }
146
+
147
+ /**
148
+ * Validate schema configuration
149
+ */
150
+ function validateSchemaConfig(dbName: string, schemaName: string, raw: any): SchemaConfig {
151
+ if (typeof raw !== 'object' || raw === null) {
152
+ throw new Error(`Schema config for '${dbName}.${schemaName}' must be an object`);
153
+ }
154
+
155
+ if (!raw.tables) {
156
+ throw new Error(`Schema '${dbName}.${schemaName}' must have 'tables' configuration`);
157
+ }
158
+
159
+ return {
160
+ tables: validateTableConfig(dbName, schemaName, raw.tables),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Validate table configuration
166
+ */
167
+ function validateTableConfig(dbName: string, schemaName: string, raw: any): TableConfig {
168
+ if (typeof raw !== 'object' || raw === null) {
169
+ throw new Error(`Table config for '${dbName}.${schemaName}' must be an object`);
170
+ }
171
+
172
+ // Error on deprecated columnExclusions format
173
+ if (raw.columnExclusions) {
174
+ throw new Error(
175
+ `Table config for '${dbName}.${schemaName}' uses deprecated 'columnExclusions' format. ` +
176
+ `Please migrate to 'columnAccess' with { mode: 'exclusion' | 'inclusion', columns: [...] } per table. ` +
177
+ `See documentation for the new format.`
178
+ );
179
+ }
180
+
181
+ // Validate mode
182
+ const validModes = ['whitelist', 'blacklist', 'none'];
183
+ if (!validModes.includes(raw.mode)) {
184
+ throw new Error(
185
+ `Table mode for '${dbName}.${schemaName}' must be one of: ${validModes.join(', ')}`
186
+ );
187
+ }
188
+
189
+ // Validate list
190
+ if (!Array.isArray(raw.list)) {
191
+ throw new Error(`Table list for '${dbName}.${schemaName}' must be an array`);
192
+ }
193
+
194
+ const list = raw.list.map((t: any) => {
195
+ if (typeof t !== 'string') {
196
+ throw new Error(`Table list for '${dbName}.${schemaName}' must contain only strings`);
197
+ }
198
+ return t; // Keep original case for display, but compare case-insensitively
199
+ });
200
+
201
+ const result: TableConfig = {
202
+ mode: raw.mode as 'whitelist' | 'blacklist' | 'none',
203
+ list,
204
+ };
205
+
206
+ // Optional column access policies
207
+ if (raw.columnAccess) {
208
+ result.columnAccess = validateColumnAccess(`${dbName}.${schemaName}`, raw.columnAccess);
209
+ }
210
+
211
+ return result;
212
+ }
213
+
214
+ /**
215
+ * Validate column access policies
216
+ */
217
+ function validateColumnAccess(
218
+ context: string,
219
+ raw: any
220
+ ): Record<string, ColumnAccessPolicy> {
221
+ if (typeof raw !== 'object' || raw === null) {
222
+ throw new Error(`Column access for '${context}' must be an object`);
223
+ }
224
+
225
+ const result: Record<string, ColumnAccessPolicy> = {};
226
+
227
+ for (const [tableName, policy] of Object.entries(raw)) {
228
+ if (typeof policy !== 'object' || policy === null) {
229
+ throw new Error(
230
+ `Column access for '${context}.${tableName}' must be an object with 'mode' and 'columns'`
231
+ );
232
+ }
233
+
234
+ const p = policy as any;
235
+
236
+ // Validate mode
237
+ const validModes = ['inclusion', 'exclusion'];
238
+ if (!validModes.includes(p.mode)) {
239
+ throw new Error(
240
+ `Column access mode for '${context}.${tableName}' must be 'inclusion' or 'exclusion'`
241
+ );
242
+ }
243
+
244
+ // Validate columns
245
+ if (!Array.isArray(p.columns)) {
246
+ throw new Error(`Column access for '${context}.${tableName}' must have 'columns' array`);
247
+ }
248
+
249
+ const columns = p.columns.map((c: any) => {
250
+ if (typeof c !== 'string') {
251
+ throw new Error(
252
+ `Column access for '${context}.${tableName}' columns must contain only strings`
253
+ );
254
+ }
255
+ return c;
256
+ });
257
+
258
+ result[tableName] = {
259
+ mode: p.mode as 'inclusion' | 'exclusion',
260
+ columns,
261
+ };
262
+ }
263
+
264
+ return result;
265
+ }
266
+
267
+ /**
268
+ * Get the effective table config for a database/schema combination
269
+ */
270
+ export function getTableConfigForSchema(
271
+ config: AccessControlConfig,
272
+ database: string,
273
+ schema: string
274
+ ): { tableConfig: TableConfig; columnAccess: Record<string, ColumnAccessPolicy> } | null {
275
+ const dbConfig = config.databases[database.toUpperCase()];
276
+ if (!dbConfig) {
277
+ return null;
278
+ }
279
+
280
+ const schemaLower = schema.toLowerCase();
281
+
282
+ // Check schema-level config first
283
+ if (dbConfig.schemas) {
284
+ // Try exact match
285
+ if (dbConfig.schemas[schemaLower]) {
286
+ const schemaConfig = dbConfig.schemas[schemaLower];
287
+ return {
288
+ tableConfig: schemaConfig.tables,
289
+ columnAccess: schemaConfig.tables.columnAccess || {},
290
+ };
291
+ }
292
+
293
+ // Try wildcard
294
+ if (dbConfig.schemas['*']) {
295
+ const schemaConfig = dbConfig.schemas['*'];
296
+ return {
297
+ tableConfig: schemaConfig.tables,
298
+ columnAccess: schemaConfig.tables.columnAccess || {},
299
+ };
300
+ }
301
+
302
+ // No matching schema config
303
+ return null;
304
+ }
305
+
306
+ // Use compact format (database-level tables config)
307
+ if (dbConfig.tables) {
308
+ return {
309
+ tableConfig: dbConfig.tables,
310
+ columnAccess: dbConfig.tables.columnAccess || {},
311
+ };
312
+ }
313
+
314
+ return null;
315
+ }