@geekmidas/cloud 10.0.0-alpha.46 → 10.0.0-alpha.47
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekmidas/cloud",
|
|
3
|
-
"version": "10.0.0-alpha.
|
|
3
|
+
"version": "10.0.0-alpha.47",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"lodash.snakecase": "~4.1.1",
|
|
44
|
-
"@geekmidas/envkit": "10.0.0-alpha.
|
|
45
|
-
"@geekmidas/manifest": "10.0.0-alpha.
|
|
44
|
+
"@geekmidas/envkit": "10.0.0-alpha.47",
|
|
45
|
+
"@geekmidas/manifest": "10.0.0-alpha.47"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/lodash.get": "~4.4.9",
|
|
@@ -58,10 +58,10 @@
|
|
|
58
58
|
"@pulumi/aws": ">=7.47.0",
|
|
59
59
|
"@pulumi/random": ">=4.21.2",
|
|
60
60
|
"@pulumi/pulumi": ">=3.263.0",
|
|
61
|
-
"@geekmidas/manifest": "^10.0.0-alpha.
|
|
62
|
-
"@geekmidas/storage": "^10.0.0-alpha.
|
|
63
|
-
"@geekmidas/events": "^10.0.0-alpha.
|
|
64
|
-
"@geekmidas/db": "^10.0.0-alpha.
|
|
61
|
+
"@geekmidas/manifest": "^10.0.0-alpha.47",
|
|
62
|
+
"@geekmidas/storage": "^10.0.0-alpha.47",
|
|
63
|
+
"@geekmidas/events": "^10.0.0-alpha.47",
|
|
64
|
+
"@geekmidas/db": "^10.0.0-alpha.47"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"@geekmidas/manifest": {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ResourceType } from '@geekmidas/envkit/sst';
|
|
2
|
+
import { providedKeyFor, provideKey } from '@geekmidas/manifest';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Encryption } from '../aws/Encryption';
|
|
5
|
+
import { provisionerFor, resolveEdges } from '../fromManifest';
|
|
6
|
+
|
|
7
|
+
// A KMS key, as far as the component reads one: what it was created with, and
|
|
8
|
+
// an ARN. The SST globals are stubbed the same way for every component.
|
|
9
|
+
vi.mock('@pulumi/aws', () => ({
|
|
10
|
+
kms: {
|
|
11
|
+
Key: class {
|
|
12
|
+
readonly arn: string;
|
|
13
|
+
constructor(
|
|
14
|
+
readonly name: string,
|
|
15
|
+
readonly args: Record<string, unknown>,
|
|
16
|
+
) {
|
|
17
|
+
this.arn = `arn:aws:kms:eu-west-1:123456789012:key/${name}`;
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const stack = {} as never;
|
|
24
|
+
|
|
25
|
+
describe('Encryption on AWS', () => {
|
|
26
|
+
it('is two KMS keys: one that rotates for data, one HMAC key for the index', () => {
|
|
27
|
+
const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
|
|
28
|
+
|
|
29
|
+
expect(pii.key).toMatchObject({
|
|
30
|
+
name: 'PiiKey',
|
|
31
|
+
args: { enableKeyRotation: true, deletionWindowInDays: 30 },
|
|
32
|
+
});
|
|
33
|
+
expect(pii.indexKey).toMatchObject({
|
|
34
|
+
name: 'PiiIndexKey',
|
|
35
|
+
args: {
|
|
36
|
+
keyUsage: 'GENERATE_VERIFY_MAC',
|
|
37
|
+
customerMasterKeySpec: 'HMAC_256',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('provides one kms:// URL, under the key the construct declared', () => {
|
|
43
|
+
const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
|
|
44
|
+
const url = new URL(pii.provides().url as string);
|
|
45
|
+
|
|
46
|
+
expect(url.protocol).toBe('kms:');
|
|
47
|
+
expect(url.hostname).toBe('eu-west-1');
|
|
48
|
+
expect(url.searchParams.get('key')).toBe(pii.key.arn);
|
|
49
|
+
expect(url.searchParams.get('index')).toBe(pii.indexKey.arn);
|
|
50
|
+
expect(
|
|
51
|
+
Object.keys(pii.provides()).map((role) =>
|
|
52
|
+
providedKeyFor('Pii', 'encryption', role),
|
|
53
|
+
),
|
|
54
|
+
).toEqual([provideKey('Pii', 'url')]);
|
|
55
|
+
expect(pii._type).toBe(ResourceType.Encryption);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('grants a linked function the calls the cipher makes, on these keys only', () => {
|
|
59
|
+
const pii = new Encryption(stack, 'Pii', { region: 'eu-west-1' });
|
|
60
|
+
|
|
61
|
+
expect(pii.getSSTLink().include).toEqual([
|
|
62
|
+
{
|
|
63
|
+
permission: {
|
|
64
|
+
actions: ['kms:GenerateDataKey', 'kms:Decrypt'],
|
|
65
|
+
resources: [pii.key.arn],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
permission: {
|
|
70
|
+
actions: ['kms:GenerateMac'],
|
|
71
|
+
resources: [pii.indexKey.arn],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is provisioned from the manifest in the stage’s region, and links its URL', () => {
|
|
78
|
+
const pii = provisionerFor('encryption')(
|
|
79
|
+
stack,
|
|
80
|
+
{ kind: 'encryption', id: 'Pii', provides: ['PII_URL'] },
|
|
81
|
+
{},
|
|
82
|
+
{} as never,
|
|
83
|
+
) as Encryption;
|
|
84
|
+
|
|
85
|
+
expect(new URL(pii.provides().url as string).hostname).toBe('eu-west-1');
|
|
86
|
+
expect(
|
|
87
|
+
resolveEdges([{ target: 'Pii', kind: 'encryption' }], { Pii: pii }),
|
|
88
|
+
).toEqual({ link: [pii], envKeys: ['PII_URL'] });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -28,6 +28,11 @@ const manifest = {
|
|
|
28
28
|
provides: ['API_URL', 'API_TRUSTED_ORIGINS', 'API_COOKIE_DOMAIN'],
|
|
29
29
|
},
|
|
30
30
|
Orders: { kind: 'database', id: 'Orders', provides: ['ORDERS_URL'] },
|
|
31
|
+
Mailer: {
|
|
32
|
+
kind: 'email',
|
|
33
|
+
id: 'Mailer',
|
|
34
|
+
provides: ['MAILER_URL', 'MAILER_FROM'],
|
|
35
|
+
},
|
|
31
36
|
Console: {
|
|
32
37
|
kind: 'site',
|
|
33
38
|
id: 'Console',
|
|
@@ -37,6 +42,7 @@ const manifest = {
|
|
|
37
42
|
{ target: 'Api', kind: 'rest-api' },
|
|
38
43
|
{ target: 'Orders', kind: 'database' },
|
|
39
44
|
{ target: 'UploadsServer', kind: 'file-server' },
|
|
45
|
+
{ target: 'Mailer', kind: 'email' },
|
|
40
46
|
],
|
|
41
47
|
provides: ['CONSOLE_URL'],
|
|
42
48
|
},
|
|
@@ -101,6 +107,10 @@ describe('siteEnvironment', () => {
|
|
|
101
107
|
Api: provided({ url: 'https://api.example.com' }),
|
|
102
108
|
Orders: provided({ url: 'postgres://user:pw@db/orders' }),
|
|
103
109
|
UploadsServer: provided({ url: 'https://files.example.com' }),
|
|
110
|
+
Mailer: provided({
|
|
111
|
+
url: 'smtp://user:pw@email-smtp.example.com:587',
|
|
112
|
+
from: 'hello@shop.test',
|
|
113
|
+
}),
|
|
104
114
|
}),
|
|
105
115
|
);
|
|
106
116
|
|
|
@@ -122,6 +132,13 @@ describe('siteEnvironment', () => {
|
|
|
122
132
|
expect(built()).not.toHaveProperty('VITE_ORDERS_URL');
|
|
123
133
|
});
|
|
124
134
|
|
|
135
|
+
it('has no inbox to share for deployed mail, and never shares the mail URL', () => {
|
|
136
|
+
// The inbox is Mailpit's, and exists only locally: deployed, an app that
|
|
137
|
+
// reads it falls back to the phone's own mail app.
|
|
138
|
+
expect(built()).not.toHaveProperty('VITE_MAILER_INBOX_URL');
|
|
139
|
+
expect(built()).not.toHaveProperty('VITE_MAILER_URL');
|
|
140
|
+
});
|
|
141
|
+
|
|
125
142
|
it('fails loudly on an edge nothing provisioned', () => {
|
|
126
143
|
// A smaller environment builds and then fails at runtime against
|
|
127
144
|
// `http:///`, with nothing to point at.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { kmsUrl } from '@geekmidas/manifest';
|
|
2
|
+
import { kms } from '@pulumi/aws';
|
|
3
|
+
import { type GkmLinkable, ResourceType } from '../Linkable';
|
|
4
|
+
import type { StackType } from '../Stack';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `Encryption` — two KMS keys, and the infra half of the `encryption` kind.
|
|
8
|
+
*
|
|
9
|
+
* One key encrypts, by envelope: each value is sealed with a data key KMS
|
|
10
|
+
* mints and wraps, so the key itself never leaves KMS. It rotates yearly, and
|
|
11
|
+
* KMS keeps every version it rotated through, so nothing written is ever
|
|
12
|
+
* stranded. The other is an HMAC key for the blind index, which does not
|
|
13
|
+
* rotate — a rotated index key would make every stored index miss.
|
|
14
|
+
*
|
|
15
|
+
* Both are reached through one `kms://` URL, and a function linked to this is
|
|
16
|
+
* granted exactly the calls the cipher makes on exactly these two keys. Not
|
|
17
|
+
* `kms:*`, and not any other key in the account.
|
|
18
|
+
*
|
|
19
|
+
* It holds the keys rather than being one, and reports its own type, so a link
|
|
20
|
+
* resolves to `<ID>_URL` — the key the construct declared.
|
|
21
|
+
*/
|
|
22
|
+
export class Encryption<
|
|
23
|
+
TStage extends string = string,
|
|
24
|
+
TDomain extends string = string,
|
|
25
|
+
> implements GkmLinkable
|
|
26
|
+
{
|
|
27
|
+
readonly _id: string;
|
|
28
|
+
|
|
29
|
+
readonly key: kms.Key;
|
|
30
|
+
readonly indexKey: kms.Key;
|
|
31
|
+
private readonly region: string;
|
|
32
|
+
|
|
33
|
+
get _type() {
|
|
34
|
+
return ResourceType.Encryption;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
_stack: StackType<TStage, TDomain>,
|
|
39
|
+
name: string,
|
|
40
|
+
props: EncryptionProps,
|
|
41
|
+
) {
|
|
42
|
+
this._id = name;
|
|
43
|
+
this.region = props.region;
|
|
44
|
+
|
|
45
|
+
this.key = new kms.Key(`${name}Key`, {
|
|
46
|
+
description: `${name}: encrypts what the application stores`,
|
|
47
|
+
enableKeyRotation: true,
|
|
48
|
+
deletionWindowInDays: props.deletionWindowInDays ?? 30,
|
|
49
|
+
});
|
|
50
|
+
this.indexKey = new kms.Key(`${name}IndexKey`, {
|
|
51
|
+
description: `${name}: the blind index's HMAC key`,
|
|
52
|
+
keyUsage: 'GENERATE_VERIFY_MAC',
|
|
53
|
+
customerMasterKeySpec: 'HMAC_256',
|
|
54
|
+
deletionWindowInDays: props.deletionWindowInDays ?? 30,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
provides(): Record<string, $util.Input<string>> {
|
|
59
|
+
return {
|
|
60
|
+
url: $util
|
|
61
|
+
.all([this.key.arn, this.indexKey.arn])
|
|
62
|
+
.apply(([key, index]) => kmsUrl({ region: this.region, key, index })),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getSSTLink() {
|
|
67
|
+
return {
|
|
68
|
+
properties: { ...this.provides() },
|
|
69
|
+
include: [
|
|
70
|
+
sst.aws.permission({
|
|
71
|
+
actions: ['kms:GenerateDataKey', 'kms:Decrypt'],
|
|
72
|
+
resources: [this.key.arn],
|
|
73
|
+
}),
|
|
74
|
+
sst.aws.permission({
|
|
75
|
+
actions: ['kms:GenerateMac'],
|
|
76
|
+
resources: [this.indexKey.arn],
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface EncryptionProps {
|
|
84
|
+
/** Where the keys live — the stage's region. */
|
|
85
|
+
region: string;
|
|
86
|
+
/**
|
|
87
|
+
* How long a deleted key can still be recovered, 7–30 days. A key's
|
|
88
|
+
* deletion is the deletion of everything it encrypted, so the longest.
|
|
89
|
+
*/
|
|
90
|
+
deletionWindowInDays?: number;
|
|
91
|
+
}
|
package/src/sst/fromManifest.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { Database, DatabaseNeedsVpc, type DatabaseProps } from './aws/Database';
|
|
|
46
46
|
import { DatabaseBootstrap } from './aws/DatabaseBootstrap';
|
|
47
47
|
import { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
|
|
48
48
|
import { Email, EmailNeedsSender } from './aws/Email';
|
|
49
|
+
import { Encryption, type EncryptionProps } from './aws/Encryption';
|
|
49
50
|
import { ExternalApi, type ExternalApiProps } from './aws/ExternalApi';
|
|
50
51
|
import { FileServer } from './aws/FileServer';
|
|
51
52
|
import { Function } from './aws/Function';
|
|
@@ -398,6 +399,17 @@ const PROVISIONERS: Partial<Record<DeclarationKind, Provisioner>> = {
|
|
|
398
399
|
// The same storage as a secret, under a different role — see the component.
|
|
399
400
|
credential: (stack, d, props) => new Credential(stack, d.id, props),
|
|
400
401
|
|
|
402
|
+
// In the stage's region, as mail is: a key in another region is a key
|
|
403
|
+
// every call crosses the world to reach.
|
|
404
|
+
encryption: (stack, d, props) =>
|
|
405
|
+
new Encryption(stack, d.id, {
|
|
406
|
+
...(props as Partial<EncryptionProps>),
|
|
407
|
+
region:
|
|
408
|
+
(props as Partial<EncryptionProps>).region ??
|
|
409
|
+
$app.providers?.aws?.region ??
|
|
410
|
+
'us-east-1',
|
|
411
|
+
}),
|
|
412
|
+
|
|
401
413
|
// Nothing to provision: the URL this stage calls, and the credentials kept
|
|
402
414
|
// as a secret.
|
|
403
415
|
'external-api': (stack, d, props) => {
|
package/src/sst/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export {
|
|
|
34
34
|
} from './aws/DatabaseBootstrap';
|
|
35
35
|
export { DatabaseReader, DatabaseSchema } from './aws/DerivedDatabase';
|
|
36
36
|
export { Email, EmailNeedsUrl, type EmailProps } from './aws/Email';
|
|
37
|
+
export { Encryption, type EncryptionProps } from './aws/Encryption';
|
|
37
38
|
export { FileServer, type FileServerProps } from './aws/FileServer';
|
|
38
39
|
export { Function, type FunctionProps } from './aws/Function';
|
|
39
40
|
export {
|