Skip to content

Commit 206b807

Browse files
committed
refactor(credentials): derive stored database connection fields and credential-supplied params from one list
The stored v1 schema now picks exactly the descriptor's login and TLS fields out of each operation schema instead of extending the whole schema, and the tools' credentialSuppliedParams come from the same list. An operation field added later can no longer change the stored blob, get pinned on every run, or stay visible on the credential-only block.
1 parent 30df76c commit 206b807

4 files changed

Lines changed: 124 additions & 18 deletions

File tree

‎apps/sim/lib/credentials/database-connections/descriptors.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,7 @@ function optionsFrom<Value extends string>(labels: Record<Value, string>) {
5555
return Object.entries<string>(labels).map(([value, label]) => ({ value, label }))
5656
}
5757

58-
export const DATABASE_CONNECTION_DESCRIPTORS: Record<
59-
DatabaseConnectionProviderId,
60-
DatabaseConnectionDescriptor
61-
> = {
58+
export const DATABASE_CONNECTION_DESCRIPTORS = {
6259
[POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID]: {
6360
providerId: POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID,
6461
service: 'postgresql',
@@ -125,6 +122,35 @@ export const DATABASE_CONNECTION_DESCRIPTORS: Record<
125122
],
126123
docsUrl: 'https://docs.sim.ai/integrations/mssql',
127124
},
125+
} as const satisfies Record<DatabaseConnectionProviderId, DatabaseConnectionDescriptor>
126+
127+
const DATABASE_LOGIN_FIELD_IDS = ['host', 'port', 'database', 'username', 'password'] as const
128+
129+
export type DatabaseConnectionFieldId<P extends DatabaseConnectionProviderId> =
130+
| (typeof DATABASE_LOGIN_FIELD_IDS)[number]
131+
| (typeof DATABASE_CONNECTION_DESCRIPTORS)[P]['tls'][number]['id']
132+
133+
/**
134+
* Every field a saved connection of this provider stores: the login, then its
135+
* transport-security settings. The stored schema keeps exactly these fields
136+
* and the tools declare exactly these as credential-supplied, so what a blob
137+
* persists, what a run replaces, and what the block hides share one list.
138+
* Changing a provider's list changes its stored blob, so it needs a new
139+
* secret type.
140+
*/
141+
export function databaseConnectionFieldIds<P extends DatabaseConnectionProviderId>(
142+
providerId: P
143+
): DatabaseConnectionFieldId<P>[] {
144+
return [
145+
...DATABASE_LOGIN_FIELD_IDS,
146+
...tlsFieldIds(DATABASE_CONNECTION_DESCRIPTORS[providerId].tls),
147+
]
148+
}
149+
150+
function tlsFieldIds<Fields extends readonly DatabaseTlsField[]>(
151+
fields: Fields
152+
): Fields[number]['id'][] {
153+
return fields.map((field) => field.id)
128154
}
129155

130156
export function isDatabaseConnectionProviderId(

‎apps/sim/lib/credentials/database-connections/server.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import {
2424
verifyAndEncryptDatabaseConnection,
2525
withSavedDatabaseConnection,
2626
} from '@/lib/credentials/database-connections/server'
27+
import {
28+
MSSQL_CREDENTIAL_OAUTH,
29+
MYSQL_CREDENTIAL_OAUTH,
30+
POSTGRESQL_CREDENTIAL_OAUTH,
31+
} from '@/tools/shared/saved-credentials'
2732

2833
const connection = {
2934
host: 'db.example.com',
@@ -59,6 +64,7 @@ const inlineInput = {
5964
ssl: 'disabled',
6065
query: 'SELECT 2',
6166
}
67+
const { ssl: _inlineSsl, ...inlineInputWithoutTls } = inlineInput
6268

6369
function call(overrides: Partial<InternalToolOperationCall> = {}): InternalToolOperationCall {
6470
return {
@@ -234,6 +240,69 @@ describe('saved database connections', () => {
234240
expect(await response.json()).toEqual({ ...connection, query: 'SELECT 2' })
235241
})
236242

243+
it.each([
244+
{
245+
providerId: 'postgresql-service-account',
246+
oauth: POSTGRESQL_CREDENTIAL_OAUTH,
247+
saved: connection,
248+
input: inlineInput,
249+
},
250+
{
251+
providerId: 'mysql-service-account',
252+
oauth: MYSQL_CREDENTIAL_OAUTH,
253+
saved: connection,
254+
input: inlineInput,
255+
},
256+
{
257+
providerId: 'mssql-service-account',
258+
oauth: MSSQL_CREDENTIAL_OAUTH,
259+
saved: connectionWithoutTls,
260+
input: {
261+
...inlineInputWithoutTls,
262+
encrypt: 'disabled',
263+
trustServerCertificate: 'enabled',
264+
connectionTimeout: 30_000,
265+
},
266+
},
267+
] as const)(
268+
'a saved $oauth.provider connection replaces exactly the params its tools declare it supplies',
269+
async ({ providerId, oauth, saved, input }) => {
270+
encryptionMockFns.mockEncryptSecret.mockImplementation(async (plain: string) => ({
271+
encrypted: plain,
272+
iv: 'iv',
273+
}))
274+
encryptionMockFns.mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
275+
decrypted: encrypted,
276+
}))
277+
const { encryptedServiceAccountKey } = await verifyAndEncryptDatabaseConnection(
278+
providerId,
279+
JSON.stringify(saved)
280+
)
281+
queueTableRows(credential, [{ ...row, providerId, encryptedServiceAccountKey }])
282+
283+
const response = await withSavedDatabaseConnection(
284+
providerId,
285+
echo
286+
)(
287+
call({
288+
input,
289+
context: {
290+
...savedConnectionContext,
291+
authorizedServiceAccountCredential: {
292+
credentialId: 'credential-1',
293+
service: oauth.provider,
294+
},
295+
},
296+
})
297+
)
298+
299+
const ran: Record<string, unknown> = await response.json()
300+
const inline: Record<string, unknown> = input
301+
const replaced = Object.keys(ran).filter((key) => ran[key] !== inline[key])
302+
expect(replaced.sort()).toEqual([...oauth.credentialSuppliedParams].sort())
303+
}
304+
)
305+
237306
it.each([
238307
['a credential for another engine', { providerId: 'mysql-service-account' }, stored],
239308
['an organization credential', { organizationId: 'organization-1' }, stored],

‎apps/sim/lib/credentials/database-connections/server.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { encryptSecret } from '@/lib/core/security/encryption'
44
import {
55
DATABASE_CONNECTION_DESCRIPTORS,
66
type DatabaseConnectionDescriptor,
7+
type DatabaseConnectionFieldId,
78
type DatabaseConnectionProviderId,
9+
databaseConnectionFieldIds,
810
} from '@/lib/credentials/database-connections/descriptors'
911
import {
1012
type CredentialOwnerScope,
@@ -43,13 +45,28 @@ const storedLoginBounds = {
4345
password: z.string().min(1).max(4096),
4446
}
4547

48+
/**
49+
* Picks exactly the provider's stored connection fields out of its operation
50+
* schema. A field the operation adds later (as SQL Server's per-block
51+
* connection timeout already is) stays out of the stored blob and is never
52+
* pinned by a saved connection.
53+
*/
54+
function storedFieldMask<P extends DatabaseConnectionProviderId>(providerId: P) {
55+
const mask: Partial<Record<DatabaseConnectionFieldId<P>, true>> = {}
56+
for (const id of databaseConnectionFieldIds(providerId)) mask[id] = true
57+
return mask as Record<DatabaseConnectionFieldId<P>, true>
58+
}
59+
4660
const postgresqlStoredConnectionSchema = postgresqlConnectionInputSchema
61+
.pick(storedFieldMask(POSTGRESQL_SERVICE_ACCOUNT_PROVIDER_ID))
62+
.extend(storedLoginBounds)
63+
.strict()
64+
const mysqlStoredConnectionSchema = mysqlConnectionInputSchema
65+
.pick(storedFieldMask(MYSQL_SERVICE_ACCOUNT_PROVIDER_ID))
4766
.extend(storedLoginBounds)
4867
.strict()
49-
const mysqlStoredConnectionSchema = mysqlConnectionInputSchema.extend(storedLoginBounds).strict()
50-
/** The connection timeout stays a per-block setting; a saved connection does not pin it. */
5168
const mssqlStoredConnectionSchema = mssqlConnectionInputSchema
52-
.omit({ connectionTimeout: true })
69+
.pick(storedFieldMask(MSSQL_SERVICE_ACCOUNT_PROVIDER_ID))
5370
.extend(storedLoginBounds)
5471
.strict()
5572

‎apps/sim/tools/shared/saved-credentials.ts‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
DATABASE_CONNECTION_DESCRIPTORS,
33
type DatabaseConnectionProviderId,
4+
databaseConnectionFieldIds,
45
} from '@/lib/credentials/database-connections/descriptors'
56
import type { OAuthService } from '@/lib/oauth'
67
import {
@@ -42,22 +43,15 @@ export const AWS_SESSION_SIGNED_TOOL_IDS: ReadonlySet<string> = new Set(['s3_get
4243
* inline connection fields working unchanged; with a saved connection
4344
* selected, the tool operation reads it server-side
4445
* (`withSavedDatabaseConnection`) and every connection field it stores
45-
* replaces the call's, so the credential supplies exactly those params.
46+
* replaces the call's. The stored fields and the params declared here are the
47+
* same list (`databaseConnectionFieldIds`).
4648
*/
4749
function databaseConnectionOAuth(providerId: DatabaseConnectionProviderId) {
48-
const { service, tls } = DATABASE_CONNECTION_DESCRIPTORS[providerId]
4950
return {
5051
required: false,
51-
provider: service,
52+
provider: DATABASE_CONNECTION_DESCRIPTORS[providerId].service,
5253
credentialKind: 'service-account',
53-
credentialSuppliedParams: [
54-
'host',
55-
'port',
56-
'database',
57-
'username',
58-
'password',
59-
...tls.map((field) => field.id),
60-
],
54+
credentialSuppliedParams: databaseConnectionFieldIds(providerId),
6155
} as const satisfies OAuthConfig
6256
}
6357

0 commit comments

Comments
 (0)