@superblocksteam/shared 0.9598.1 → 0.9599.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.
- package/dist/database-lifecycle/index.d.ts +26 -2
- package/dist/database-lifecycle/index.d.ts.map +1 -1
- package/dist/database-lifecycle/index.js +122 -3
- package/dist/database-lifecycle/index.js.map +1 -1
- package/dist/database-lifecycle/index.test.js +90 -6
- package/dist/database-lifecycle/index.test.js.map +1 -1
- package/dist/plugins/templates/postgres.d.ts.map +1 -1
- package/dist/plugins/templates/postgres.js +164 -1
- package/dist/plugins/templates/postgres.js.map +1 -1
- package/dist/plugins/templates/postgres.test.d.ts +2 -0
- package/dist/plugins/templates/postgres.test.d.ts.map +1 -0
- package/dist/plugins/templates/postgres.test.js +224 -0
- package/dist/plugins/templates/postgres.test.js.map +1 -0
- package/dist/types/datasource/auth.d.ts +4 -0
- package/dist/types/datasource/auth.d.ts.map +1 -1
- package/dist/types/datasource/auth.js +6 -1
- package/dist/types/datasource/auth.js.map +1 -1
- package/dist/types/datasource/index.d.ts +104 -3
- package/dist/types/datasource/index.d.ts.map +1 -1
- package/dist/types/datasource/index.js.map +1 -1
- package/dist/types/deployment/deployQueueStatus.d.ts +3 -1
- package/dist/types/deployment/deployQueueStatus.d.ts.map +1 -1
- package/dist/types/deployment/deployQueueStatus.js +2 -0
- package/dist/types/deployment/deployQueueStatus.js.map +1 -1
- package/dist-esm/database-lifecycle/index.d.ts +26 -2
- package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/index.js +117 -2
- package/dist-esm/database-lifecycle/index.js.map +1 -1
- package/dist-esm/database-lifecycle/index.test.js +91 -7
- package/dist-esm/database-lifecycle/index.test.js.map +1 -1
- package/dist-esm/plugins/templates/postgres.d.ts.map +1 -1
- package/dist-esm/plugins/templates/postgres.js +165 -2
- package/dist-esm/plugins/templates/postgres.js.map +1 -1
- package/dist-esm/plugins/templates/postgres.test.d.ts +2 -0
- package/dist-esm/plugins/templates/postgres.test.d.ts.map +1 -0
- package/dist-esm/plugins/templates/postgres.test.js +222 -0
- package/dist-esm/plugins/templates/postgres.test.js.map +1 -0
- package/dist-esm/types/datasource/auth.d.ts +4 -0
- package/dist-esm/types/datasource/auth.d.ts.map +1 -1
- package/dist-esm/types/datasource/auth.js +5 -0
- package/dist-esm/types/datasource/auth.js.map +1 -1
- package/dist-esm/types/datasource/index.d.ts +104 -3
- package/dist-esm/types/datasource/index.d.ts.map +1 -1
- package/dist-esm/types/datasource/index.js.map +1 -1
- package/dist-esm/types/deployment/deployQueueStatus.d.ts +3 -1
- package/dist-esm/types/deployment/deployQueueStatus.d.ts.map +1 -1
- package/dist-esm/types/deployment/deployQueueStatus.js +2 -0
- package/dist-esm/types/deployment/deployQueueStatus.js.map +1 -1
- package/package.json +1 -1
- package/src/database-lifecycle/index.test.ts +107 -6
- package/src/database-lifecycle/index.ts +153 -10
- package/src/plugins/templates/postgres.test.ts +231 -0
- package/src/plugins/templates/postgres.ts +166 -2
- package/src/types/datasource/auth.ts +5 -0
- package/src/types/datasource/index.ts +115 -3
- package/src/types/deployment/deployQueueStatus.ts +3 -1
|
@@ -3,10 +3,14 @@ import { describe, expect, test } from 'vitest';
|
|
|
3
3
|
import {
|
|
4
4
|
computeBindingKey,
|
|
5
5
|
computeDesiredSpecHash,
|
|
6
|
+
computeNativeDbApplicationToken,
|
|
7
|
+
computeNativeDbDatabaseName,
|
|
6
8
|
computeRequirementKey,
|
|
7
9
|
containsCredentialMaterial,
|
|
8
10
|
isSecretKey,
|
|
9
11
|
isTerminalLifecycleState,
|
|
12
|
+
nativeDbIamSessionPolicy,
|
|
13
|
+
parseNativeDbIamDescriptor,
|
|
10
14
|
redactLifecycleSecrets
|
|
11
15
|
} from './index.js';
|
|
12
16
|
|
|
@@ -26,24 +30,25 @@ describe('database lifecycle contracts', () => {
|
|
|
26
30
|
expect(computeRequirementKey(requirement)).toBe('orders-db~Orders%20DB:postgres');
|
|
27
31
|
expect(computeRequirementKey({ logicalName: 'orders/db', engine: 'postgres' })).toBe('orders-db~orders%2Fdb:postgres');
|
|
28
32
|
expect(computeRequirementKey({ logicalName: 'orders-db', engine: 'postgres' })).toBe('orders-db~orders-db:postgres');
|
|
33
|
+
// binding_key excludes the logical name on purpose: one database per
|
|
34
|
+
// (organization, application, environment, profile). Two different logical
|
|
35
|
+
// names under the same profile resolve to the same binding_key.
|
|
29
36
|
expect(
|
|
30
37
|
computeBindingKey({
|
|
31
38
|
organizationId: 'org-1',
|
|
32
39
|
applicationId: 'app-1',
|
|
33
40
|
environment: 'edit',
|
|
34
|
-
profile: 'Staging EU'
|
|
35
|
-
requirementKey: 'orders-db~Orders%20DB:postgres'
|
|
41
|
+
profile: 'Staging EU'
|
|
36
42
|
})
|
|
37
|
-
).toBe('org-1:app-1:edit:staging-eu~Staging%20EU
|
|
43
|
+
).toBe('org-1:app-1:edit:staging-eu~Staging%20EU');
|
|
38
44
|
expect(
|
|
39
45
|
computeBindingKey({
|
|
40
46
|
organizationId: 'org-1',
|
|
41
47
|
applicationId: 'app-1',
|
|
42
48
|
environment: 'edit',
|
|
43
|
-
profile: 'staging'
|
|
44
|
-
requirementKey: 'orders-db~Orders%20DB:postgres'
|
|
49
|
+
profile: 'staging'
|
|
45
50
|
})
|
|
46
|
-
).toBe('org-1:app-1:edit:staging~staging
|
|
51
|
+
).toBe('org-1:app-1:edit:staging~staging');
|
|
47
52
|
|
|
48
53
|
await expect(computeDesiredSpecHash(requirement)).resolves.toBe(
|
|
49
54
|
await computeDesiredSpecHash({ ...requirement, extensions: ['uuid-ossp', 'pgcrypto'] })
|
|
@@ -51,6 +56,25 @@ describe('database lifecycle contracts', () => {
|
|
|
51
56
|
await expect(computeDesiredSpecHash(requirement)).resolves.toBe('QsCl6RVzJVEwf4Nuk0D27rRtycYe2vQZMxcXLt9WUkQ=');
|
|
52
57
|
});
|
|
53
58
|
|
|
59
|
+
test('ignores migrationDirectory when hashing the desired spec', async () => {
|
|
60
|
+
const infrastructureSpec = {
|
|
61
|
+
logicalName: 'Orders DB',
|
|
62
|
+
engine: 'postgres',
|
|
63
|
+
version: '16',
|
|
64
|
+
sizing: { storageGb: 20, tier: 'shared' },
|
|
65
|
+
extensions: ['pgcrypto', 'uuid-ossp']
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
const withoutDirectory = await computeDesiredSpecHash(infrastructureSpec);
|
|
69
|
+
const withDirectory = await computeDesiredSpecHash({ ...infrastructureSpec, migrationDirectory: 'db/migrations' });
|
|
70
|
+
const withOtherDirectory = await computeDesiredSpecHash({ ...infrastructureSpec, migrationDirectory: 'db/other-migrations' });
|
|
71
|
+
|
|
72
|
+
// migrationDirectory only says where the SQL files live, not what
|
|
73
|
+
// infrastructure to provision, so changing it must not look like drift.
|
|
74
|
+
expect(withDirectory).toBe(withoutDirectory);
|
|
75
|
+
expect(withOtherDirectory).toBe(withoutDirectory);
|
|
76
|
+
});
|
|
77
|
+
|
|
54
78
|
test('hashes desired specs with locale-invariant ordering', async () => {
|
|
55
79
|
const first = await computeDesiredSpecHash({
|
|
56
80
|
logicalName: 'Éclair DB',
|
|
@@ -129,4 +153,81 @@ describe('database lifecycle contracts', () => {
|
|
|
129
153
|
expect(containsCredentialMaterial('host=db.internal port=5432')).toBe(false);
|
|
130
154
|
expect(containsCredentialMaterial('arn:aws:secretsmanager:us-east-1:123:secret:orders')).toBe(false);
|
|
131
155
|
});
|
|
156
|
+
|
|
157
|
+
test('builds a canonical session policy from a valid managed Postgres IAM descriptor', () => {
|
|
158
|
+
const descriptor = parseNativeDbIamDescriptor({
|
|
159
|
+
application_id: 'application-id',
|
|
160
|
+
auth_descriptor_version: 1,
|
|
161
|
+
auth_mode: 'aws_iam_role',
|
|
162
|
+
aws_account_id: '123456789012',
|
|
163
|
+
binding_id: 'binding-id',
|
|
164
|
+
cluster_resource_id: 'cluster-ABC123DEF456EXAMPLE',
|
|
165
|
+
connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
|
|
166
|
+
database: 'sbndb_abcdef123456_0123456789abcdef01234567',
|
|
167
|
+
host: 'native-db.cluster-example.us-west-2.rds.amazonaws.com',
|
|
168
|
+
port: 5432,
|
|
169
|
+
region: 'us-west-2',
|
|
170
|
+
username: 'sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime'
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(descriptor).toBeDefined();
|
|
174
|
+
if (!descriptor) {
|
|
175
|
+
throw new Error('Expected a valid IAM descriptor');
|
|
176
|
+
}
|
|
177
|
+
expect(nativeDbIamSessionPolicy(descriptor)).toBe(
|
|
178
|
+
'{"Version":"2012-10-17","Statement":[{"Sid":"ConnectToThisNativeDatabaseUser","Effect":"Allow","Action":"rds-db:connect","Resource":"arn:aws:rds-db:us-west-2:123456789012:dbuser:cluster-ABC123DEF456EXAMPLE/sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime"}]}'
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('rejects malformed or non-commercial managed Postgres IAM descriptors', () => {
|
|
183
|
+
const descriptor = {
|
|
184
|
+
application_id: 'application-id',
|
|
185
|
+
auth_descriptor_version: 1,
|
|
186
|
+
auth_mode: 'aws_iam_role',
|
|
187
|
+
aws_account_id: '123456789012',
|
|
188
|
+
binding_id: 'binding-id',
|
|
189
|
+
cluster_resource_id: 'cluster-ABC123DEF456EXAMPLE',
|
|
190
|
+
connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
|
|
191
|
+
database: 'sbndb_abcdef123456_0123456789abcdef01234567',
|
|
192
|
+
host: 'native-db.cluster-example.us-west-2.rds.amazonaws.com',
|
|
193
|
+
port: 5432,
|
|
194
|
+
region: 'us-west-2',
|
|
195
|
+
username: 'sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime'
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, auth_descriptor_version: 2 })).toBeUndefined();
|
|
199
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, cluster_resource_id: 'not-an-rds-resource' })).toBeUndefined();
|
|
200
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, connector_role_arn: 'arn:aws:iam::210987654321:role/other' })).toBeUndefined();
|
|
201
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, database: 'orders' })).toBeUndefined();
|
|
202
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, host: descriptor.host.toUpperCase() })).toBeUndefined();
|
|
203
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, host: 'native-db.cluster-example.us-east-1.rds.amazonaws.com' })).toBeUndefined();
|
|
204
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, port: 0 })).toBeUndefined();
|
|
205
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, region: 'cn-north-1' })).toBeUndefined();
|
|
206
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, region: 'us-gov-west-1' })).toBeUndefined();
|
|
207
|
+
expect(parseNativeDbIamDescriptor({ ...descriptor, username: 'sbndb_000000000000_7bfeae1170db923274f1943f_runtime' })).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('accepts standalone RDS IAM resource IDs', () => {
|
|
211
|
+
expect(
|
|
212
|
+
parseNativeDbIamDescriptor({
|
|
213
|
+
application_id: 'application-id',
|
|
214
|
+
auth_descriptor_version: 1,
|
|
215
|
+
auth_mode: 'aws_iam_role',
|
|
216
|
+
aws_account_id: '123456789012',
|
|
217
|
+
binding_id: 'binding-id',
|
|
218
|
+
cluster_resource_id: 'db-ABCDEF0123456789-1',
|
|
219
|
+
connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
|
|
220
|
+
database: 'sbndb_abcdef123456_0123456789abcdef01234567',
|
|
221
|
+
host: 'native-db.example.us-west-2.rds.amazonaws.com',
|
|
222
|
+
port: 5432,
|
|
223
|
+
region: 'us-west-2',
|
|
224
|
+
username: 'sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime'
|
|
225
|
+
})
|
|
226
|
+
).toBeDefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('derives canonical IAM database and runtime-user identity tokens', async () => {
|
|
230
|
+
await expect(computeNativeDbApplicationToken('application-id')).resolves.toBe('7bfeae1170db923274f1943f');
|
|
231
|
+
await expect(computeNativeDbDatabaseName('abcdef123456', 'database-id')).resolves.toBe('sbndb_abcdef123456_dad72612f7a80580b028178f');
|
|
232
|
+
});
|
|
132
233
|
});
|
|
@@ -16,6 +16,11 @@ export const LIFECYCLE_OPERATIONS = ['ensure_database', 'migrate_schema', 'retir
|
|
|
16
16
|
export const LIFECYCLE_ENVIRONMENTS = ['edit', 'preview', 'deployed'] as const;
|
|
17
17
|
export const DATABASE_ENGINES = ['postgres', 'snowflake', 'snowflake_postgres', 'lakebase'] as const;
|
|
18
18
|
export const DATABASE_LIFECYCLE_MANAGED_BY = 'database_lifecycle';
|
|
19
|
+
export const NATIVE_DB_AUTH_DESCRIPTOR_VERSION = 1 as const;
|
|
20
|
+
export const NATIVE_DB_CONNECTOR_ROLE_NAME_PREFIX = 'superblocks-native-db-connector';
|
|
21
|
+
export const NATIVE_DB_IAM_AUTH_MODE = 'aws_iam_role' as const;
|
|
22
|
+
export const NATIVE_DB_IDENTIFIER_HASH_DOMAIN = 'superblocks-native-db:v1';
|
|
23
|
+
export const DATABASE_LIFECYCLE_CAPABILITY_MANAGED_IAM_V1 = 'managed-IAM-v1';
|
|
19
24
|
|
|
20
25
|
// Agent capability tag keys. A lifecycle worker publishes these in the
|
|
21
26
|
// `tags` map of its agent registration (merged into — never replacing — the
|
|
@@ -28,6 +33,7 @@ export const DATABASE_LIFECYCLE_MANAGED_BY = 'database_lifecycle';
|
|
|
28
33
|
export const DATABASE_LIFECYCLE_TAG_OPERATIONS = 'databaseLifecycle:operations';
|
|
29
34
|
export const DATABASE_LIFECYCLE_TAG_ENGINES = 'databaseLifecycle:engines';
|
|
30
35
|
export const DATABASE_LIFECYCLE_TAG_ENVIRONMENT_PROFILES = 'databaseLifecycle:environmentProfiles';
|
|
36
|
+
export const DATABASE_LIFECYCLE_TAG_CAPABILITIES = 'databaseLifecycle:capabilities';
|
|
31
37
|
|
|
32
38
|
export type LifecycleEnvironment = (typeof LIFECYCLE_ENVIRONMENTS)[number];
|
|
33
39
|
export type DatabaseEngine = (typeof DATABASE_ENGINES)[number];
|
|
@@ -60,6 +66,21 @@ export type CredentialRef = {
|
|
|
60
66
|
field?: string;
|
|
61
67
|
};
|
|
62
68
|
|
|
69
|
+
export type NativeDbIamDescriptor = {
|
|
70
|
+
application_id: string;
|
|
71
|
+
auth_descriptor_version: typeof NATIVE_DB_AUTH_DESCRIPTOR_VERSION;
|
|
72
|
+
auth_mode: typeof NATIVE_DB_IAM_AUTH_MODE;
|
|
73
|
+
aws_account_id: string;
|
|
74
|
+
binding_id: string;
|
|
75
|
+
cluster_resource_id: string;
|
|
76
|
+
connector_role_arn: string;
|
|
77
|
+
database: string;
|
|
78
|
+
host: string;
|
|
79
|
+
port: number;
|
|
80
|
+
region: string;
|
|
81
|
+
username: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
63
84
|
export type DatabaseRequirement = {
|
|
64
85
|
logicalName: string;
|
|
65
86
|
engine: DatabaseEngine;
|
|
@@ -177,6 +198,8 @@ export type LifecycleDispatchContinuation = {
|
|
|
177
198
|
// worker's `MigrationState` at the default "pending"; an empty slice means
|
|
178
199
|
// "vacuous truth, mark migrated"; a non-empty slice triggers the runner.
|
|
179
200
|
export type LifecycleDispatchPayload = {
|
|
201
|
+
applicationId: string;
|
|
202
|
+
bindingId: string;
|
|
180
203
|
bindingKey: string;
|
|
181
204
|
connectionMetadata?: Record<string, string | number | boolean>;
|
|
182
205
|
continuation?: LifecycleDispatchContinuation;
|
|
@@ -196,12 +219,18 @@ export function computeRequirementKey(requirement: Pick<DatabaseRequirement, 'lo
|
|
|
196
219
|
return `${slugify(requirement.logicalName)}~${encodeURIComponent(requirement.logicalName)}:${requirement.engine}`;
|
|
197
220
|
}
|
|
198
221
|
|
|
222
|
+
// binding_key is the product identity of a database in the control plane, and
|
|
223
|
+
// is deliberately scoped to (organization, application, environment, profile)
|
|
224
|
+
// only — NOT the logical name. This is what makes "one database per profile"
|
|
225
|
+
// enforceable by the plain UNIQUE (binding_key) constraint: asking for another
|
|
226
|
+
// database under a different logical name resolves to the same binding_key
|
|
227
|
+
// instead of minting a new one. The logical name still lives on the binding row
|
|
228
|
+
// and in resource_key (physical identity); it just doesn't grant a second slot.
|
|
199
229
|
export function computeBindingKey(input: {
|
|
200
230
|
organizationId: string;
|
|
201
231
|
applicationId: string;
|
|
202
232
|
environment: LifecycleEnvironment;
|
|
203
233
|
profile: string;
|
|
204
|
-
requirementKey: string;
|
|
205
234
|
}): string {
|
|
206
235
|
return [input.organizationId, ...bindingKeySegments(input)].join(':');
|
|
207
236
|
}
|
|
@@ -210,22 +239,114 @@ export function computeLegacyBindingKeyWithoutOrganization(input: {
|
|
|
210
239
|
applicationId: string;
|
|
211
240
|
environment: LifecycleEnvironment;
|
|
212
241
|
profile: string;
|
|
213
|
-
requirementKey: string;
|
|
214
242
|
}): string {
|
|
215
243
|
return bindingKeySegments(input).join(':');
|
|
216
244
|
}
|
|
217
245
|
|
|
218
|
-
function bindingKeySegments(input: {
|
|
219
|
-
applicationId
|
|
220
|
-
environment: LifecycleEnvironment;
|
|
221
|
-
profile: string;
|
|
222
|
-
requirementKey: string;
|
|
223
|
-
}): string[] {
|
|
224
|
-
return [input.applicationId, input.environment, `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`, input.requirementKey];
|
|
246
|
+
function bindingKeySegments(input: { applicationId: string; environment: LifecycleEnvironment; profile: string }): string[] {
|
|
247
|
+
return [input.applicationId, input.environment, `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`];
|
|
225
248
|
}
|
|
226
249
|
|
|
250
|
+
// Hashes only the infrastructure spec. migrationDirectory is deliberately
|
|
251
|
+
// excluded: it says where the SQL files live, not what database to provision,
|
|
252
|
+
// so changing it must not make a ready binding look drifted and re-dispatch
|
|
253
|
+
// ensure_database. Callers that persist the requirement must still rewrite
|
|
254
|
+
// desiredSpec when migrationDirectory changes (same hash is not "no-op").
|
|
255
|
+
// Specs without a migrationDirectory keep their existing hash.
|
|
227
256
|
export async function computeDesiredSpecHash(requirement: DatabaseRequirement): Promise<string> {
|
|
228
|
-
|
|
257
|
+
const infrastructureSpec = { ...requirement };
|
|
258
|
+
delete infrastructureSpec.migrationDirectory;
|
|
259
|
+
return await sha256Base64(JSON.stringify(canonicalize(infrastructureSpec)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function nativeDbIamSessionPolicy(descriptor: NativeDbIamDescriptor): string {
|
|
263
|
+
return JSON.stringify({
|
|
264
|
+
Version: '2012-10-17',
|
|
265
|
+
Statement: [
|
|
266
|
+
{
|
|
267
|
+
Sid: 'ConnectToThisNativeDatabaseUser',
|
|
268
|
+
Effect: 'Allow',
|
|
269
|
+
Action: 'rds-db:connect',
|
|
270
|
+
Resource: `arn:aws:rds-db:${descriptor.region}:${descriptor.aws_account_id}:dbuser:${descriptor.cluster_resource_id}/${descriptor.username}`
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function parseNativeDbIamDescriptor(value: unknown): NativeDbIamDescriptor | undefined {
|
|
277
|
+
if (!isUnknownRecord(value)) {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const databaseIdentifier = typeof value.database === 'string' ? /^sbndb_([0-9a-f]{12})_([0-9a-f]{24})$/.exec(value.database) : null;
|
|
282
|
+
const usernameIdentifier =
|
|
283
|
+
typeof value.username === 'string' ? /^sbndb_([0-9a-f]{12})_([0-9a-f]{24})_runtime$/.exec(value.username) : null;
|
|
284
|
+
const connectorRoleArn =
|
|
285
|
+
typeof value.connector_role_arn === 'string'
|
|
286
|
+
? /^arn:aws:iam::(\d{12}):role\/(?:[\w+=,.@-]+\/)*[\w+=,.@-]+$/.exec(value.connector_role_arn)
|
|
287
|
+
: null;
|
|
288
|
+
if (
|
|
289
|
+
!isNonEmptyString(value.application_id) ||
|
|
290
|
+
value.auth_descriptor_version !== NATIVE_DB_AUTH_DESCRIPTOR_VERSION ||
|
|
291
|
+
value.auth_mode !== NATIVE_DB_IAM_AUTH_MODE ||
|
|
292
|
+
!isNonEmptyString(value.aws_account_id) ||
|
|
293
|
+
!/^\d{12}$/.test(value.aws_account_id) ||
|
|
294
|
+
!isNonEmptyString(value.binding_id) ||
|
|
295
|
+
!isNonEmptyString(value.cluster_resource_id) ||
|
|
296
|
+
!/^(cluster|db)-[A-Za-z0-9-]+$/.test(value.cluster_resource_id) ||
|
|
297
|
+
!connectorRoleArn ||
|
|
298
|
+
connectorRoleArn[1] !== value.aws_account_id ||
|
|
299
|
+
!databaseIdentifier ||
|
|
300
|
+
!usernameIdentifier ||
|
|
301
|
+
databaseIdentifier[1] !== usernameIdentifier[1] ||
|
|
302
|
+
!isValidNativeDbRdsHostname(value.host, value.region) ||
|
|
303
|
+
typeof value.port !== 'number' ||
|
|
304
|
+
!Number.isInteger(value.port) ||
|
|
305
|
+
value.port < 1 ||
|
|
306
|
+
value.port > 65535 ||
|
|
307
|
+
!isCommercialAwsRegion(value.region)
|
|
308
|
+
) {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
application_id: value.application_id,
|
|
314
|
+
auth_descriptor_version: NATIVE_DB_AUTH_DESCRIPTOR_VERSION,
|
|
315
|
+
auth_mode: NATIVE_DB_IAM_AUTH_MODE,
|
|
316
|
+
aws_account_id: value.aws_account_id,
|
|
317
|
+
binding_id: value.binding_id,
|
|
318
|
+
cluster_resource_id: value.cluster_resource_id,
|
|
319
|
+
connector_role_arn: connectorRoleArn[0],
|
|
320
|
+
database: databaseIdentifier[0],
|
|
321
|
+
host: value.host,
|
|
322
|
+
port: value.port,
|
|
323
|
+
region: value.region,
|
|
324
|
+
username: usernameIdentifier[0]
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function computeNativeDbApplicationToken(trustedApplicationId: string): Promise<string> {
|
|
329
|
+
return (await computeNativeDbIdentifierToken('application', trustedApplicationId)).slice(0, 24);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function computeNativeDbDatabaseName(deploymentToken: string, trustedDatabaseId: string): Promise<string> {
|
|
333
|
+
if (!/^[0-9a-f]{12}$/.test(deploymentToken)) {
|
|
334
|
+
throw new Error('Native database deployment token must be 12 lowercase hexadecimal characters');
|
|
335
|
+
}
|
|
336
|
+
return `sbndb_${deploymentToken}_${(await computeNativeDbIdentifierToken('database', trustedDatabaseId)).slice(0, 24)}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function computeNativeDbIdentifierToken(kind: 'application' | 'database', trustedId: string): Promise<string> {
|
|
340
|
+
if (!isNonEmptyString(trustedId)) {
|
|
341
|
+
throw new Error(`Trusted native database ${kind} ID must be a non-empty string`);
|
|
342
|
+
}
|
|
343
|
+
const digest = await crypto.subtle.digest(
|
|
344
|
+
'SHA-256',
|
|
345
|
+
new TextEncoder().encode(`${NATIVE_DB_IDENTIFIER_HASH_DOMAIN}:${kind}:${trustedId}`)
|
|
346
|
+
);
|
|
347
|
+
return Array.from(new Uint8Array(digest))
|
|
348
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
349
|
+
.join('');
|
|
229
350
|
}
|
|
230
351
|
|
|
231
352
|
// resource_key identifies the physical resource a binding maps to in
|
|
@@ -292,6 +413,28 @@ function codepointCompare(left: string, right: string): number {
|
|
|
292
413
|
return 0;
|
|
293
414
|
}
|
|
294
415
|
|
|
416
|
+
function isCommercialAwsRegion(value: unknown): value is string {
|
|
417
|
+
return typeof value === 'string' && /^[a-z]{2}-[a-z]+-[0-9]+$/.test(value) && !value.startsWith('cn-') && !value.startsWith('us-gov-');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
421
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
425
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function isValidNativeDbRdsHostname(host: unknown, region: unknown): host is string {
|
|
429
|
+
if (typeof host !== 'string' || typeof region !== 'string') {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
const suffix = `.${region}.rds.amazonaws.com`;
|
|
433
|
+
const prefix = host.endsWith(suffix) ? host.slice(0, -suffix.length) : '';
|
|
434
|
+
const validLabel = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
435
|
+
return host.length <= 253 && host === host.toLowerCase() && prefix !== '' && host.split('.').every((label) => validLabel.test(label));
|
|
436
|
+
}
|
|
437
|
+
|
|
295
438
|
function redact(value: unknown, preserveCredentialRefs = false): unknown {
|
|
296
439
|
if (Array.isArray(value)) {
|
|
297
440
|
return value.map((entry) => redact(entry, preserveCredentialRefs));
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { FormComponentType, getRowItemsFromSectionItem } from '../../types/index.js';
|
|
4
|
+
import { PostgresPlugin } from './postgres.js';
|
|
5
|
+
|
|
6
|
+
const datasourceItems = PostgresPlugin.datasourceTemplate?.sections.flatMap((section) => section.items.flatMap(getRowItemsFromSectionItem));
|
|
7
|
+
|
|
8
|
+
const findDatasourceItems = (name: string) => datasourceItems?.filter((item) => item.name === name) ?? [];
|
|
9
|
+
|
|
10
|
+
describe('Postgres datasource template', () => {
|
|
11
|
+
it('gates IAM fields only by Postgres plugin version 0.0.13', () => {
|
|
12
|
+
const iamFieldNames = [
|
|
13
|
+
'authentication.authType',
|
|
14
|
+
'authentication.custom.databaseName.value',
|
|
15
|
+
'authentication.custom.iamRoleArn.value',
|
|
16
|
+
'authentication.custom.region.value',
|
|
17
|
+
'authentication.custom.sessionPolicy.value',
|
|
18
|
+
'authentication.username',
|
|
19
|
+
'connection.useSsl'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
for (const fieldName of iamFieldNames) {
|
|
23
|
+
const versionThirteenItems = findDatasourceItems(fieldName).filter((item) => item.startVersion === '0.0.13');
|
|
24
|
+
expect(versionThirteenItems.length).toBeGreaterThan(0);
|
|
25
|
+
expect(versionThirteenItems.every((item) => item.ldFlag === undefined)).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('adds the version 0.0.13 authentication method and IAM role fields', () => {
|
|
30
|
+
expect(findDatasourceItems('authentication.authType')).toEqual([
|
|
31
|
+
expect.objectContaining({
|
|
32
|
+
componentType: FormComponentType.DROPDOWN,
|
|
33
|
+
display: {
|
|
34
|
+
show: {
|
|
35
|
+
connectionType: ['fields']
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
initialValue: 'password',
|
|
39
|
+
options: [
|
|
40
|
+
{
|
|
41
|
+
displayName: 'Username & password',
|
|
42
|
+
key: 'password',
|
|
43
|
+
value: 'password'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
displayName: 'AWS IAM Role',
|
|
47
|
+
key: 'aws_iam_role',
|
|
48
|
+
value: 'aws_iam_role'
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
startVersion: '0.0.13'
|
|
52
|
+
})
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
expect(findDatasourceItems('authentication.custom.iamRoleArn.value')).toEqual([
|
|
56
|
+
expect.objectContaining({
|
|
57
|
+
display: {
|
|
58
|
+
show: {
|
|
59
|
+
'authentication.authType': ['aws_iam_role'],
|
|
60
|
+
connectionType: ['fields']
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
rules: [{ message: 'Role ARN is required', required: true }],
|
|
64
|
+
startVersion: '0.0.13'
|
|
65
|
+
})
|
|
66
|
+
]);
|
|
67
|
+
expect(findDatasourceItems('authentication.custom.region.value')).toEqual([
|
|
68
|
+
expect.objectContaining({
|
|
69
|
+
display: {
|
|
70
|
+
show: {
|
|
71
|
+
'authentication.authType': ['aws_iam_role'],
|
|
72
|
+
connectionType: ['fields']
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
rules: [{ message: 'Region is required', required: true }],
|
|
76
|
+
startVersion: '0.0.13'
|
|
77
|
+
})
|
|
78
|
+
]);
|
|
79
|
+
expect(findDatasourceItems('authentication.custom.sessionPolicy.value')).toEqual([
|
|
80
|
+
expect.objectContaining({
|
|
81
|
+
display: {
|
|
82
|
+
show: {
|
|
83
|
+
'authentication.authType': ['aws_iam_role'],
|
|
84
|
+
connectionType: ['fields']
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
rules: [{ required: false }],
|
|
88
|
+
startVersion: '0.0.13'
|
|
89
|
+
})
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps username visible and password compatible with legacy configurations', () => {
|
|
94
|
+
expect(findDatasourceItems('authentication.username')).toEqual([
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
endVersion: '0.0.10',
|
|
97
|
+
startVersion: '0.0.1'
|
|
98
|
+
}),
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
display: {
|
|
101
|
+
show: {
|
|
102
|
+
connectionType: ['fields']
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
endVersion: '0.0.12',
|
|
106
|
+
startVersion: '0.0.11'
|
|
107
|
+
}),
|
|
108
|
+
expect.objectContaining({
|
|
109
|
+
display: {
|
|
110
|
+
show: {
|
|
111
|
+
'authentication.authType': ['', 'undefined', 'password'],
|
|
112
|
+
connectionType: ['fields']
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
startVersion: '0.0.13'
|
|
116
|
+
}),
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
display: {
|
|
119
|
+
show: {
|
|
120
|
+
'authentication.authType': ['aws_iam_role'],
|
|
121
|
+
connectionType: ['fields']
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
rules: [{ message: 'Database username is required', required: true }],
|
|
125
|
+
startVersion: '0.0.13'
|
|
126
|
+
})
|
|
127
|
+
]);
|
|
128
|
+
expect(findDatasourceItems('authentication.password')).toEqual([
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
endVersion: '0.0.10',
|
|
131
|
+
startVersion: '0.0.1'
|
|
132
|
+
}),
|
|
133
|
+
expect.objectContaining({
|
|
134
|
+
display: {
|
|
135
|
+
show: {
|
|
136
|
+
connectionType: ['fields']
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
endVersion: '0.0.12',
|
|
140
|
+
startVersion: '0.0.11'
|
|
141
|
+
}),
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
display: {
|
|
144
|
+
show: {
|
|
145
|
+
'authentication.authType': ['', 'undefined', 'password'],
|
|
146
|
+
connectionType: ['fields']
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
startVersion: '0.0.13'
|
|
150
|
+
})
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('requires a database name only for IAM role authentication', () => {
|
|
155
|
+
expect(findDatasourceItems('authentication.custom.databaseName.value')).toEqual([
|
|
156
|
+
expect.objectContaining({
|
|
157
|
+
endVersion: '0.0.10',
|
|
158
|
+
startVersion: '0.0.1'
|
|
159
|
+
}),
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
display: {
|
|
162
|
+
show: {
|
|
163
|
+
connectionType: ['fields']
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
endVersion: '0.0.12',
|
|
167
|
+
startVersion: '0.0.11'
|
|
168
|
+
}),
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
display: {
|
|
171
|
+
show: {
|
|
172
|
+
'authentication.authType': ['', 'undefined', 'password'],
|
|
173
|
+
connectionType: ['fields']
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
startVersion: '0.0.13'
|
|
177
|
+
}),
|
|
178
|
+
expect.objectContaining({
|
|
179
|
+
display: {
|
|
180
|
+
show: {
|
|
181
|
+
'authentication.authType': ['aws_iam_role'],
|
|
182
|
+
connectionType: ['fields']
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
rules: [{ message: 'Database name is required', required: true }],
|
|
186
|
+
startVersion: '0.0.13'
|
|
187
|
+
})
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('requires TLS for IAM role authentication without changing password mode', () => {
|
|
192
|
+
expect(findDatasourceItems('connection.useSsl')).toEqual([
|
|
193
|
+
expect.objectContaining({
|
|
194
|
+
endVersion: '0.0.10',
|
|
195
|
+
startVersion: '0.0.1'
|
|
196
|
+
}),
|
|
197
|
+
expect.objectContaining({
|
|
198
|
+
display: {
|
|
199
|
+
show: {
|
|
200
|
+
connectionType: ['fields']
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
endVersion: '0.0.12',
|
|
204
|
+
startVersion: '0.0.11'
|
|
205
|
+
}),
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
disabled: false,
|
|
208
|
+
display: {
|
|
209
|
+
show: {
|
|
210
|
+
'authentication.authType': ['', 'undefined', 'password'],
|
|
211
|
+
connectionType: ['fields']
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
initialValue: true,
|
|
215
|
+
startVersion: '0.0.13'
|
|
216
|
+
}),
|
|
217
|
+
expect.objectContaining({
|
|
218
|
+
disabled: true,
|
|
219
|
+
display: {
|
|
220
|
+
show: {
|
|
221
|
+
'authentication.authType': ['aws_iam_role'],
|
|
222
|
+
connectionType: ['fields']
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
initialValue: true,
|
|
226
|
+
rules: [{ enum: [true], message: 'SSL is required for AWS IAM role authentication' }],
|
|
227
|
+
startVersion: '0.0.13'
|
|
228
|
+
})
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
});
|