@auth-craft/aws-cf-stack 1.8.0 → 1.10.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/.env.example +15 -0
- package/README.md +82 -4
- package/assets/admin/cli.mjs +90 -90
- package/assets/gateway/worker.js +1 -1
- package/assets/lambda/index.mjs +52 -50
- package/bin/deploy.sh +12 -0
- package/cdk/bin/app.ts +13 -2
- package/cdk/lib/dynamodb-stack.ts +59 -90
- package/cdk/lib/lambda-stack.ts +101 -4
- package/cdk/lib/table-schema.json +192 -0
- package/lib/common.sh +10 -0
- package/lib/deploy-lambda.sh +4 -0
- package/lib/sync-table-gsis.sh +257 -0
- package/package.json +17 -9
- package/cdk/cdk.out/asset.530567cf1e6fd5236cd0e483ba02427949a4a68b619db930921f8d3ba9f03689/index.mjs +0 -135
- package/cdk/cdk.out/cdk.out +0 -1
- package/cdk/cdk.out/manifest.json +0 -565
- package/cdk/cdk.out/snapshot-commerce-prod-auth-api.assets.json +0 -36
- package/cdk/cdk.out/snapshot-commerce-prod-auth-api.metadata.json +0 -277
- package/cdk/cdk.out/snapshot-commerce-prod-auth-api.template.json +0 -412
- package/cdk/cdk.out/snapshot-commerce-prod-auth-data.assets.json +0 -21
- package/cdk/cdk.out/snapshot-commerce-prod-auth-data.metadata.json +0 -151
- package/cdk/cdk.out/snapshot-commerce-prod-auth-data.template.json +0 -371
- package/cdk/cdk.out/tree.json +0 -1
package/bin/deploy.sh
CHANGED
|
@@ -17,6 +17,8 @@ export PKG_ROOT
|
|
|
17
17
|
source "$PKG_ROOT/lib/common.sh"
|
|
18
18
|
# shellcheck source=../lib/deploy-lambda.sh
|
|
19
19
|
source "$PKG_ROOT/lib/deploy-lambda.sh"
|
|
20
|
+
# shellcheck source=../lib/sync-table-gsis.sh
|
|
21
|
+
source "$PKG_ROOT/lib/sync-table-gsis.sh"
|
|
20
22
|
# shellcheck source=../lib/create-admin.sh
|
|
21
23
|
source "$PKG_ROOT/lib/create-admin.sh"
|
|
22
24
|
# shellcheck source=../lib/deploy-gateway.sh
|
|
@@ -31,6 +33,8 @@ Usage:
|
|
|
31
33
|
|
|
32
34
|
Commands:
|
|
33
35
|
lambda Deploy DynamoDB + Lambda (CDK). Prints FunctionUrl, ApiBasePath, table.
|
|
36
|
+
An existing table missing GSIs gets them first, one per update
|
|
37
|
+
(needs dynamodb:DescribeTable + cloudformation:GetTemplate).
|
|
34
38
|
Use --outputs-file to capture CDK outputs as JSON for your own steps.
|
|
35
39
|
gateway Deploy the 3 gateway workers (system/tenant/customer) via wrangler,
|
|
36
40
|
wiring BACKEND_URL/base-path/secrets from the live CDK outputs. Reads
|
|
@@ -54,6 +58,12 @@ Options (each also reads an env var; the flag wins):
|
|
|
54
58
|
--edge-gate-secret <v> env CF_EDGE_GATE_SECRET
|
|
55
59
|
--cf-account-id <v> env CLOUDFLARE_ACCOUNT_ID
|
|
56
60
|
--cf-api-token <v> env CLOUDFLARE_API_TOKEN
|
|
61
|
+
--log-retention-days <n> CloudWatch log retention (env LAMBDA_LOG_RETENTION_DAYS).
|
|
62
|
+
Default: 3 in dev, 30 otherwise. Use 'infinite' to never
|
|
63
|
+
expire. Only CloudWatch's fixed set is valid
|
|
64
|
+
(1,3,5,7,14,30,60,90,120,150,180,365,400,545,731,…).
|
|
65
|
+
--tracing <mode> X-Ray tracing: active|disabled|pass_through
|
|
66
|
+
(env LAMBDA_TRACING; default disabled in dev, active otherwise)
|
|
57
67
|
--outputs-file <path> (lambda) write CDK outputs JSON here for your own steps
|
|
58
68
|
--env-file <path> Optional: source a file first (flags/env still override)
|
|
59
69
|
--skip-build No-op (artifacts are prebuilt in the package)
|
|
@@ -89,6 +99,8 @@ while [[ $# -gt 0 ]]; do
|
|
|
89
99
|
--edge-gate-secret) FLAG[CF_EDGE_GATE_SECRET]="${2:?}"; shift 2 ;;
|
|
90
100
|
--cf-account-id) FLAG[CLOUDFLARE_ACCOUNT_ID]="${2:?}"; shift 2 ;;
|
|
91
101
|
--cf-api-token) FLAG[CLOUDFLARE_API_TOKEN]="${2:?}"; shift 2 ;;
|
|
102
|
+
--log-retention-days) FLAG[LAMBDA_LOG_RETENTION_DAYS]="${2:?}"; shift 2 ;;
|
|
103
|
+
--tracing) FLAG[LAMBDA_TRACING]="${2:?}"; shift 2 ;;
|
|
92
104
|
--outputs-file) OUTPUTS_FILE="${2:?}"; export OUTPUTS_FILE; shift 2 ;;
|
|
93
105
|
--env-file) ENV_FILE="${2:?}"; shift 2 ;;
|
|
94
106
|
--skip-build) shift ;;
|
package/cdk/bin/app.ts
CHANGED
|
@@ -27,8 +27,7 @@ const env = {
|
|
|
27
27
|
// it carries no component so the iss claim stays stable across resources.
|
|
28
28
|
// Examples: snapshot-commerce-prod-auth-data, snapshot-commerce-prod-auth-api
|
|
29
29
|
|
|
30
|
-
const appName =
|
|
31
|
-
process.env.AUTH_CRAFT_APP_NAME || (project === 'default' ? 'auth-craft' : `auth-craft-${project}`);
|
|
30
|
+
const appName = process.env.AUTH_CRAFT_APP_NAME || (project === 'default' ? 'auth-craft' : `auth-craft-${project}`);
|
|
32
31
|
|
|
33
32
|
// Base name shared by every resource (also AUTH_SYSTEM_NAME / JWT issuer).
|
|
34
33
|
const resourceBase = `${appName}-${stage}-${layer}`;
|
|
@@ -50,6 +49,17 @@ const costTags = {
|
|
|
50
49
|
// Stack / resource naming helper: {app}-{env}-{layer}-{component}
|
|
51
50
|
const nameFor = (component: string) => `${resourceBase}-${component}`;
|
|
52
51
|
|
|
52
|
+
// GSI rollout: while adding missing GSIs to an existing table one per update, the deploy CLI
|
|
53
|
+
// passes the subset as CDK context (`-c dataGsis=A,B,...`) on that one command line. It is
|
|
54
|
+
// deliberately not an env var: an exported value (shell, --env-file) would leak into the
|
|
55
|
+
// full deploy and make CloudFormation drop every unlisted GSI. Absent or blank = every GSI.
|
|
56
|
+
const dataGsiContext: unknown = app.node.tryGetContext('dataGsis');
|
|
57
|
+
const dataGsiList = (typeof dataGsiContext === 'string' ? dataGsiContext : '')
|
|
58
|
+
.split(',')
|
|
59
|
+
.map((name) => name.trim())
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
const dataGsis = dataGsiList.length > 0 ? dataGsiList : undefined;
|
|
62
|
+
|
|
53
63
|
// DynamoDB Stack (data component)
|
|
54
64
|
const dynamodbStack = new DynamoDBStack(app, nameFor('data'), {
|
|
55
65
|
env,
|
|
@@ -58,6 +68,7 @@ const dynamodbStack = new DynamoDBStack(app, nameFor('data'), {
|
|
|
58
68
|
tags: costTags,
|
|
59
69
|
tableName: nameFor('data'),
|
|
60
70
|
stage,
|
|
71
|
+
...(dataGsis ? { gsiNames: dataGsis } : {}),
|
|
61
72
|
});
|
|
62
73
|
|
|
63
74
|
// Lambda Stack (api component)
|
|
@@ -1,10 +1,52 @@
|
|
|
1
1
|
import * as cdk from 'aws-cdk-lib';
|
|
2
2
|
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
|
|
3
3
|
import type { Construct } from 'constructs';
|
|
4
|
+
import tableSchema from './table-schema.json' with { type: 'json' };
|
|
4
5
|
|
|
5
6
|
interface DynamoDBStackProps extends cdk.StackProps {
|
|
6
7
|
tableName: string; // {app}-{env}-{layer}-data, e.g. "snapshot-commerce-prod-auth-data"
|
|
7
8
|
stage: string; // "dev" | "staging" | "prod" — passed from the app, single source of truth
|
|
9
|
+
/**
|
|
10
|
+
* Only declare these GSIs (schema order kept). Used by the deploy CLI to add missing
|
|
11
|
+
* GSIs to an existing table one per update — CloudFormation rejects more than one GSI
|
|
12
|
+
* creation per table update. Omit to declare every GSI in the schema.
|
|
13
|
+
*/
|
|
14
|
+
gsiNames?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type KeyAttribute = { name: string; type: string };
|
|
18
|
+
type GsiDefinition = (typeof tableSchema.globalSecondaryIndexes)[number];
|
|
19
|
+
|
|
20
|
+
// The schema is generated from @auth-craft/database-plugin-dynamodb (TABLE_SCHEMA) by
|
|
21
|
+
// scripts/sync-table-schema.mjs — edit it there, never here.
|
|
22
|
+
export const TABLE_GSI_NAMES: readonly string[] = tableSchema.globalSecondaryIndexes.map((gsi) => gsi.indexName);
|
|
23
|
+
|
|
24
|
+
function toAttribute(key: KeyAttribute): dynamodb.Attribute {
|
|
25
|
+
const types: Record<string, dynamodb.AttributeType> = {
|
|
26
|
+
S: dynamodb.AttributeType.STRING,
|
|
27
|
+
N: dynamodb.AttributeType.NUMBER,
|
|
28
|
+
B: dynamodb.AttributeType.BINARY,
|
|
29
|
+
};
|
|
30
|
+
const type = types[key.type];
|
|
31
|
+
if (!type) throw new Error(`Unknown attribute type "${key.type}" for ${key.name}`);
|
|
32
|
+
return { name: key.name, type };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toGsiProps(gsi: GsiDefinition): dynamodb.GlobalSecondaryIndexProps {
|
|
36
|
+
const sortKey = 'sortKey' in gsi && gsi.sortKey ? { sortKey: toAttribute(gsi.sortKey) } : {};
|
|
37
|
+
const projection = gsi.projection as { type: string; nonKeyAttributes?: string[] };
|
|
38
|
+
const projectionProps: Pick<dynamodb.GlobalSecondaryIndexProps, 'projectionType' | 'nonKeyAttributes'> =
|
|
39
|
+
projection.type === 'INCLUDE'
|
|
40
|
+
? { projectionType: dynamodb.ProjectionType.INCLUDE, nonKeyAttributes: projection.nonKeyAttributes ?? [] }
|
|
41
|
+
: projection.type === 'KEYS_ONLY'
|
|
42
|
+
? { projectionType: dynamodb.ProjectionType.KEYS_ONLY }
|
|
43
|
+
: { projectionType: dynamodb.ProjectionType.ALL };
|
|
44
|
+
return {
|
|
45
|
+
indexName: gsi.indexName,
|
|
46
|
+
partitionKey: toAttribute(gsi.partitionKey),
|
|
47
|
+
...sortKey,
|
|
48
|
+
...projectionProps,
|
|
49
|
+
};
|
|
8
50
|
}
|
|
9
51
|
|
|
10
52
|
export class DynamoDBStack extends cdk.Stack {
|
|
@@ -13,108 +55,35 @@ export class DynamoDBStack extends cdk.Stack {
|
|
|
13
55
|
constructor(scope: Construct, id: string, props: DynamoDBStackProps) {
|
|
14
56
|
super(scope, id, props);
|
|
15
57
|
|
|
16
|
-
const { tableName, stage } = props;
|
|
58
|
+
const { tableName, stage, gsiNames } = props;
|
|
17
59
|
|
|
18
60
|
// Table naming: {app}-{env}-{layer}-data
|
|
19
61
|
// Examples: snapshot-commerce-prod-auth-data, auth-craft-dev-auth-data
|
|
20
62
|
|
|
21
63
|
this.table = new dynamodb.Table(this, 'AuthCraftTable', {
|
|
22
64
|
tableName,
|
|
23
|
-
partitionKey:
|
|
24
|
-
sortKey:
|
|
65
|
+
partitionKey: toAttribute(tableSchema.partitionKey),
|
|
66
|
+
sortKey: toAttribute(tableSchema.sortKey),
|
|
25
67
|
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // On-demand billing
|
|
26
|
-
timeToLiveAttribute:
|
|
68
|
+
timeToLiveAttribute: tableSchema.ttlAttribute, // Enable TTL for automatic expiration
|
|
27
69
|
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: stage === 'prod' }, // Enable backups in production
|
|
28
70
|
removalPolicy: stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
|
|
29
71
|
deletionProtection: stage === 'prod', // Protect production table from accidental deletion
|
|
30
72
|
});
|
|
31
73
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// GSI_UserSessions: Query all sessions for a user
|
|
46
|
-
// PK: USR#{userId}, SK: {createdAtMillis}#{sessionId}
|
|
47
|
-
this.table.addGlobalSecondaryIndex({
|
|
48
|
-
indexName: 'GSI_UserSessions',
|
|
49
|
-
partitionKey: { name: 'userSessionPk', type: dynamodb.AttributeType.STRING },
|
|
50
|
-
sortKey: { name: 'userSessionSk', type: dynamodb.AttributeType.STRING },
|
|
51
|
-
projectionType: dynamodb.ProjectionType.KEYS_ONLY,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// GSI_UsersByStatus: List users by status (active/inactive)
|
|
55
|
-
// PK: USR#ACTIVE or USR#INACTIVE, SK: {userId}
|
|
56
|
-
// INCLUDE: displayName, createdAt
|
|
57
|
-
this.table.addGlobalSecondaryIndex({
|
|
58
|
-
indexName: 'GSI_UsersByStatus',
|
|
59
|
-
partitionKey: { name: 'userStatusPk', type: dynamodb.AttributeType.STRING },
|
|
60
|
-
sortKey: { name: 'userStatusSk', type: dynamodb.AttributeType.STRING },
|
|
61
|
-
projectionType: dynamodb.ProjectionType.INCLUDE,
|
|
62
|
-
nonKeyAttributes: ['displayName', 'createdAt'],
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// GSI_UserProviders: Query OAuth providers for a user
|
|
66
|
-
// PK: USR#{userId}, SK: PRV#{provider}#{externalId}
|
|
67
|
-
this.table.addGlobalSecondaryIndex({
|
|
68
|
-
indexName: 'GSI_UserProviders',
|
|
69
|
-
partitionKey: { name: 'userProviderPk', type: dynamodb.AttributeType.STRING },
|
|
70
|
-
sortKey: { name: 'userProviderSk', type: dynamodb.AttributeType.STRING },
|
|
71
|
-
projectionType: dynamodb.ProjectionType.INCLUDE,
|
|
72
|
-
nonKeyAttributes: ['userId', 'externalEmail', 'externalName', 'createdAt'],
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
// GSI_UserIdentities: Query all identities for a user (SCHEMA_V4)
|
|
76
|
-
// PK: USR#{userId}, SK: IDT#{type}#{value}
|
|
77
|
-
this.table.addGlobalSecondaryIndex({
|
|
78
|
-
indexName: 'GSI_UserIdentities',
|
|
79
|
-
partitionKey: { name: 'identityUserPk', type: dynamodb.AttributeType.STRING },
|
|
80
|
-
sortKey: { name: 'identityUserSk', type: dynamodb.AttributeType.STRING },
|
|
81
|
-
projectionType: dynamodb.ProjectionType.INCLUDE,
|
|
82
|
-
nonKeyAttributes: ['loginEnabled', 'verifiedAt', 'createdAt'],
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// GSI_UserCredentials: Query all credentials for a user (SCHEMA_V4)
|
|
86
|
-
// PK: USR#{userId}, SK: CRD#{type}#{credentialId}
|
|
87
|
-
// INCLUDE: revokedAt, createdAt
|
|
88
|
-
this.table.addGlobalSecondaryIndex({
|
|
89
|
-
indexName: 'GSI_UserCredentials',
|
|
90
|
-
partitionKey: { name: 'credentialUserPk', type: dynamodb.AttributeType.STRING },
|
|
91
|
-
sortKey: { name: 'credentialUserSk', type: dynamodb.AttributeType.STRING },
|
|
92
|
-
projectionType: dynamodb.ProjectionType.INCLUDE,
|
|
93
|
-
nonKeyAttributes: ['revokedAt', 'createdAt'],
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// ============================================
|
|
97
|
-
// SCHEMA_V5+ Global Secondary Indexes
|
|
98
|
-
// ============================================
|
|
99
|
-
|
|
100
|
-
// GSI_UserTenants: Query all tenants for a user (SCHEMA_V7)
|
|
101
|
-
// PK: USR#{userId}, SK: {createdAtMillis}#{tenantId}
|
|
102
|
-
this.table.addGlobalSecondaryIndex({
|
|
103
|
-
indexName: 'GSI_UserTenants',
|
|
104
|
-
partitionKey: { name: 'userTenantPk', type: dynamodb.AttributeType.STRING },
|
|
105
|
-
sortKey: { name: 'userTenantSk', type: dynamodb.AttributeType.STRING },
|
|
106
|
-
projectionType: dynamodb.ProjectionType.INCLUDE,
|
|
107
|
-
nonKeyAttributes: ['userStatus', 'role', 'roles', 'perm', 'authPerm', 'isOwner', 'createdAt', 'updatedAt'],
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
// GSI_TenantInvites: Query invites by tenant (SCHEMA_V5)
|
|
111
|
-
// PK: TNT#{tenantId}, SK: {createdAtMillis}#{inviteId}
|
|
112
|
-
this.table.addGlobalSecondaryIndex({
|
|
113
|
-
indexName: 'GSI_TenantInvites',
|
|
114
|
-
partitionKey: { name: 'inviteTenantPk', type: dynamodb.AttributeType.STRING },
|
|
115
|
-
sortKey: { name: 'inviteTenantSk', type: dynamodb.AttributeType.STRING },
|
|
116
|
-
projectionType: dynamodb.ProjectionType.ALL,
|
|
117
|
-
});
|
|
74
|
+
// Global Secondary Indexes — see TABLE_SCHEMA in database-plugin-dynamodb for each
|
|
75
|
+
// index's key layout and access pattern.
|
|
76
|
+
if (gsiNames && gsiNames.length === 0) {
|
|
77
|
+
throw new Error('gsiNames is empty — omit it to declare every GSI (an empty list would delete them all)');
|
|
78
|
+
}
|
|
79
|
+
const unknown = (gsiNames ?? []).filter((name) => !TABLE_GSI_NAMES.includes(name));
|
|
80
|
+
if (unknown.length > 0) {
|
|
81
|
+
throw new Error(`Unknown GSI(s) requested: ${unknown.join(', ')} (schema has ${TABLE_GSI_NAMES.join(', ')})`);
|
|
82
|
+
}
|
|
83
|
+
for (const gsi of tableSchema.globalSecondaryIndexes) {
|
|
84
|
+
if (gsiNames && !gsiNames.includes(gsi.indexName)) continue;
|
|
85
|
+
this.table.addGlobalSecondaryIndex(toGsiProps(gsi));
|
|
86
|
+
}
|
|
118
87
|
|
|
119
88
|
// Outputs
|
|
120
89
|
new cdk.CfnOutput(this, 'TableName', {
|
package/cdk/lib/lambda-stack.ts
CHANGED
|
@@ -16,6 +16,78 @@ const __dirname = path.dirname(__filename);
|
|
|
16
16
|
// packages/aws-cf-stack/cdk/lib/lambda-stack.ts -> packages/aws-cf-stack/assets/lambda
|
|
17
17
|
const LAMBDA_ASSET_DIR = path.resolve(__dirname, '../../assets/lambda');
|
|
18
18
|
|
|
19
|
+
// CloudWatch Logs only accepts a fixed set of retention periods — an arbitrary day
|
|
20
|
+
// count is rejected at deploy time. Map the supported values so a typo fails fast at
|
|
21
|
+
// synth (with the valid list) instead of mid-deploy.
|
|
22
|
+
const RETENTION_BY_DAYS: Record<number, logs.RetentionDays> = {
|
|
23
|
+
1: logs.RetentionDays.ONE_DAY,
|
|
24
|
+
3: logs.RetentionDays.THREE_DAYS,
|
|
25
|
+
5: logs.RetentionDays.FIVE_DAYS,
|
|
26
|
+
7: logs.RetentionDays.ONE_WEEK,
|
|
27
|
+
14: logs.RetentionDays.TWO_WEEKS,
|
|
28
|
+
30: logs.RetentionDays.ONE_MONTH,
|
|
29
|
+
60: logs.RetentionDays.TWO_MONTHS,
|
|
30
|
+
90: logs.RetentionDays.THREE_MONTHS,
|
|
31
|
+
120: logs.RetentionDays.FOUR_MONTHS,
|
|
32
|
+
150: logs.RetentionDays.FIVE_MONTHS,
|
|
33
|
+
180: logs.RetentionDays.SIX_MONTHS,
|
|
34
|
+
365: logs.RetentionDays.ONE_YEAR,
|
|
35
|
+
400: logs.RetentionDays.THIRTEEN_MONTHS,
|
|
36
|
+
545: logs.RetentionDays.EIGHTEEN_MONTHS,
|
|
37
|
+
731: logs.RetentionDays.TWO_YEARS,
|
|
38
|
+
1096: logs.RetentionDays.THREE_YEARS,
|
|
39
|
+
1827: logs.RetentionDays.FIVE_YEARS,
|
|
40
|
+
2192: logs.RetentionDays.SIX_YEARS,
|
|
41
|
+
2557: logs.RetentionDays.SEVEN_YEARS,
|
|
42
|
+
2922: logs.RetentionDays.EIGHT_YEARS,
|
|
43
|
+
3288: logs.RetentionDays.NINE_YEARS,
|
|
44
|
+
3653: logs.RetentionDays.TEN_YEARS,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the CloudWatch log retention from LAMBDA_LOG_RETENTION_DAYS.
|
|
49
|
+
* Accepts a supported day count (e.g. "3") or 'infinite' / 'never' to keep logs
|
|
50
|
+
* forever. Unset → stage default: 3 days in dev (cheap), 1 month otherwise.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveLogRetention(raw: string | undefined, stage: string): logs.RetentionDays {
|
|
53
|
+
const value = raw?.trim().toLowerCase();
|
|
54
|
+
|
|
55
|
+
if (!value) {
|
|
56
|
+
return stage === 'dev' ? logs.RetentionDays.THREE_DAYS : logs.RetentionDays.ONE_MONTH;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (value === 'infinite' || value === 'never' || value === '0') {
|
|
60
|
+
return logs.RetentionDays.INFINITE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const days = Number(value);
|
|
64
|
+
const retention = Number.isInteger(days) ? RETENTION_BY_DAYS[days] : undefined;
|
|
65
|
+
if (retention === undefined) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Unsupported LAMBDA_LOG_RETENTION_DAYS: '${raw}'. ` +
|
|
68
|
+
`CloudWatch Logs accepts only: ${Object.keys(RETENTION_BY_DAYS).join(', ')} (or 'infinite').`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return retention;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve X-Ray tracing from LAMBDA_TRACING ('active' | 'disabled').
|
|
76
|
+
* Unset → disabled in dev (traces are billed per request), active otherwise.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveTracing(raw: string | undefined, stage: string): lambda.Tracing {
|
|
79
|
+
const value = raw?.trim().toLowerCase();
|
|
80
|
+
|
|
81
|
+
if (!value) {
|
|
82
|
+
return stage === 'dev' ? lambda.Tracing.DISABLED : lambda.Tracing.ACTIVE;
|
|
83
|
+
}
|
|
84
|
+
if (value === 'active' || value === 'true' || value === 'enabled') return lambda.Tracing.ACTIVE;
|
|
85
|
+
if (value === 'disabled' || value === 'false' || value === 'off') return lambda.Tracing.DISABLED;
|
|
86
|
+
if (value === 'pass_through' || value === 'passthrough') return lambda.Tracing.PASS_THROUGH;
|
|
87
|
+
|
|
88
|
+
throw new Error(`Unsupported LAMBDA_TRACING: '${raw}'. Use 'active', 'disabled', or 'pass_through'.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
19
91
|
interface LambdaStackProps extends cdk.StackProps {
|
|
20
92
|
table: dynamodb.ITable;
|
|
21
93
|
functionName: string; // {app}-{env}-{layer}-api, e.g. "snapshot-commerce-prod-auth-api"
|
|
@@ -93,13 +165,24 @@ export class LambdaStack extends cdk.Stack {
|
|
|
93
165
|
// Naming: functionName = {app}-{env}-{layer}-api (the deployed resource);
|
|
94
166
|
// resourceName = {app}-{env}-{layer} base (AUTH_SYSTEM_NAME / JWT issuer).
|
|
95
167
|
|
|
96
|
-
// Log group for Lambda function (matches the function name)
|
|
168
|
+
// Log group for Lambda function (matches the function name).
|
|
169
|
+
// Retention drives CloudWatch Logs storage cost, so it is configurable:
|
|
170
|
+
// LAMBDA_LOG_RETENTION_DAYS accepts any retention CloudWatch supports, plus
|
|
171
|
+
// 'infinite' (never expire). Unset → a stage-aware default that keeps dev cheap
|
|
172
|
+
// (3 days) while staging/prod keep a useful history (1 month).
|
|
173
|
+
const retention = resolveLogRetention(process.env.LAMBDA_LOG_RETENTION_DAYS, stage);
|
|
174
|
+
|
|
97
175
|
const logGroup = new logs.LogGroup(this, 'AuthLambdaLogGroup', {
|
|
98
176
|
logGroupName: `/aws/lambda/${functionName}`,
|
|
99
|
-
retention
|
|
177
|
+
retention,
|
|
100
178
|
removalPolicy: stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
|
|
101
179
|
});
|
|
102
180
|
|
|
181
|
+
// X-Ray tracing is billed per trace and is rarely worth it in dev, so it is
|
|
182
|
+
// opt-out there: LAMBDA_TRACING = 'active' | 'disabled' (default: disabled in
|
|
183
|
+
// dev, active elsewhere).
|
|
184
|
+
const tracing = resolveTracing(process.env.LAMBDA_TRACING, stage);
|
|
185
|
+
|
|
103
186
|
// Lambda function from the PREBUILT bundle shipped in this package.
|
|
104
187
|
// Unlike the in-repo app stack (which uses NodejsFunction to build from
|
|
105
188
|
// src/index.ts at synth time), this self-contained package deploys the
|
|
@@ -234,8 +317,8 @@ export class LambdaStack extends cdk.Stack {
|
|
|
234
317
|
}),
|
|
235
318
|
},
|
|
236
319
|
|
|
237
|
-
// Tracing
|
|
238
|
-
tracing
|
|
320
|
+
// Tracing (X-Ray) — see resolveTracing above; off by default in dev.
|
|
321
|
+
tracing,
|
|
239
322
|
});
|
|
240
323
|
|
|
241
324
|
// Grant DynamoDB permissions
|
|
@@ -291,6 +374,20 @@ export class LambdaStack extends cdk.Stack {
|
|
|
291
374
|
exportName: `${this.stackName}-ApiBasePath`,
|
|
292
375
|
});
|
|
293
376
|
|
|
377
|
+
// Cost-relevant knobs, echoed so `auth-craft-stack outputs` can show what was
|
|
378
|
+
// actually deployed (rather than what the caller thinks they set).
|
|
379
|
+
new cdk.CfnOutput(this, 'LogRetentionDays', {
|
|
380
|
+
value: String(retention),
|
|
381
|
+
description: 'CloudWatch log retention in days (0 = never expire)',
|
|
382
|
+
exportName: `${this.stackName}-LogRetentionDays`,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
new cdk.CfnOutput(this, 'TracingMode', {
|
|
386
|
+
value: tracing,
|
|
387
|
+
description: 'X-Ray tracing mode',
|
|
388
|
+
exportName: `${this.stackName}-TracingMode`,
|
|
389
|
+
});
|
|
390
|
+
|
|
294
391
|
// Output auth system name — used as JWT issuer by Workers to verify tokens
|
|
295
392
|
new cdk.CfnOutput(this, 'AuthSystemName', {
|
|
296
393
|
value: resourceName,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
{
|
|
2
|
+
"partitionKey": {
|
|
3
|
+
"name": "PK",
|
|
4
|
+
"type": "S"
|
|
5
|
+
},
|
|
6
|
+
"sortKey": {
|
|
7
|
+
"name": "SK",
|
|
8
|
+
"type": "S"
|
|
9
|
+
},
|
|
10
|
+
"ttlAttribute": "TTL",
|
|
11
|
+
"globalSecondaryIndexes": [
|
|
12
|
+
{
|
|
13
|
+
"indexName": "GSI_ActiveSessions",
|
|
14
|
+
"partitionKey": {
|
|
15
|
+
"name": "activeSessionPk",
|
|
16
|
+
"type": "S"
|
|
17
|
+
},
|
|
18
|
+
"sortKey": {
|
|
19
|
+
"name": "activeSessionSk",
|
|
20
|
+
"type": "S"
|
|
21
|
+
},
|
|
22
|
+
"projection": {
|
|
23
|
+
"type": "KEYS_ONLY"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"indexName": "GSI_UserSessions",
|
|
28
|
+
"partitionKey": {
|
|
29
|
+
"name": "userSessionPk",
|
|
30
|
+
"type": "S"
|
|
31
|
+
},
|
|
32
|
+
"sortKey": {
|
|
33
|
+
"name": "userSessionSk",
|
|
34
|
+
"type": "S"
|
|
35
|
+
},
|
|
36
|
+
"projection": {
|
|
37
|
+
"type": "KEYS_ONLY"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"indexName": "GSI_UsersByStatus",
|
|
42
|
+
"partitionKey": {
|
|
43
|
+
"name": "userStatusPk",
|
|
44
|
+
"type": "S"
|
|
45
|
+
},
|
|
46
|
+
"sortKey": {
|
|
47
|
+
"name": "userStatusSk",
|
|
48
|
+
"type": "S"
|
|
49
|
+
},
|
|
50
|
+
"projection": {
|
|
51
|
+
"type": "INCLUDE",
|
|
52
|
+
"nonKeyAttributes": [
|
|
53
|
+
"displayName",
|
|
54
|
+
"createdAt"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"indexName": "GSI_UserProviders",
|
|
60
|
+
"partitionKey": {
|
|
61
|
+
"name": "userProviderPk",
|
|
62
|
+
"type": "S"
|
|
63
|
+
},
|
|
64
|
+
"sortKey": {
|
|
65
|
+
"name": "userProviderSk",
|
|
66
|
+
"type": "S"
|
|
67
|
+
},
|
|
68
|
+
"projection": {
|
|
69
|
+
"type": "INCLUDE",
|
|
70
|
+
"nonKeyAttributes": [
|
|
71
|
+
"userId",
|
|
72
|
+
"externalEmail",
|
|
73
|
+
"externalName",
|
|
74
|
+
"createdAt"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"indexName": "GSI_UserIdentities",
|
|
80
|
+
"partitionKey": {
|
|
81
|
+
"name": "identityUserPk",
|
|
82
|
+
"type": "S"
|
|
83
|
+
},
|
|
84
|
+
"sortKey": {
|
|
85
|
+
"name": "identityUserSk",
|
|
86
|
+
"type": "S"
|
|
87
|
+
},
|
|
88
|
+
"projection": {
|
|
89
|
+
"type": "INCLUDE",
|
|
90
|
+
"nonKeyAttributes": [
|
|
91
|
+
"loginEnabled",
|
|
92
|
+
"verifiedAt",
|
|
93
|
+
"createdAt"
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"indexName": "GSI_UserCredentials",
|
|
99
|
+
"partitionKey": {
|
|
100
|
+
"name": "credentialUserPk",
|
|
101
|
+
"type": "S"
|
|
102
|
+
},
|
|
103
|
+
"sortKey": {
|
|
104
|
+
"name": "credentialUserSk",
|
|
105
|
+
"type": "S"
|
|
106
|
+
},
|
|
107
|
+
"projection": {
|
|
108
|
+
"type": "INCLUDE",
|
|
109
|
+
"nonKeyAttributes": [
|
|
110
|
+
"revokedAt",
|
|
111
|
+
"createdAt"
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"indexName": "GSI_UserTenants",
|
|
117
|
+
"partitionKey": {
|
|
118
|
+
"name": "userTenantPk",
|
|
119
|
+
"type": "S"
|
|
120
|
+
},
|
|
121
|
+
"sortKey": {
|
|
122
|
+
"name": "userTenantSk",
|
|
123
|
+
"type": "S"
|
|
124
|
+
},
|
|
125
|
+
"projection": {
|
|
126
|
+
"type": "INCLUDE",
|
|
127
|
+
"nonKeyAttributes": [
|
|
128
|
+
"userStatus",
|
|
129
|
+
"role",
|
|
130
|
+
"roles",
|
|
131
|
+
"perm",
|
|
132
|
+
"authPerm",
|
|
133
|
+
"isOwner",
|
|
134
|
+
"createdAt",
|
|
135
|
+
"updatedAt"
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"indexName": "GSI_TenantInvites",
|
|
141
|
+
"partitionKey": {
|
|
142
|
+
"name": "inviteTenantPk",
|
|
143
|
+
"type": "S"
|
|
144
|
+
},
|
|
145
|
+
"sortKey": {
|
|
146
|
+
"name": "inviteTenantSk",
|
|
147
|
+
"type": "S"
|
|
148
|
+
},
|
|
149
|
+
"projection": {
|
|
150
|
+
"type": "ALL"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"indexName": "GSI_InviteToken",
|
|
155
|
+
"partitionKey": {
|
|
156
|
+
"name": "inviteTokenGsi",
|
|
157
|
+
"type": "S"
|
|
158
|
+
},
|
|
159
|
+
"projection": {
|
|
160
|
+
"type": "KEYS_ONLY"
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"indexName": "GSI_InviteeEmail",
|
|
165
|
+
"partitionKey": {
|
|
166
|
+
"name": "inviteeEmailGsi",
|
|
167
|
+
"type": "S"
|
|
168
|
+
},
|
|
169
|
+
"sortKey": {
|
|
170
|
+
"name": "inviteeEmailTenantSk",
|
|
171
|
+
"type": "S"
|
|
172
|
+
},
|
|
173
|
+
"projection": {
|
|
174
|
+
"type": "ALL"
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"indexName": "GSI_InviteePhone",
|
|
179
|
+
"partitionKey": {
|
|
180
|
+
"name": "inviteePhoneGsi",
|
|
181
|
+
"type": "S"
|
|
182
|
+
},
|
|
183
|
+
"sortKey": {
|
|
184
|
+
"name": "inviteePhoneTenantSk",
|
|
185
|
+
"type": "S"
|
|
186
|
+
},
|
|
187
|
+
"projection": {
|
|
188
|
+
"type": "ALL"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}
|
package/lib/common.sh
CHANGED
|
@@ -259,6 +259,14 @@ print_outputs_json() {
|
|
|
259
259
|
# in a Service Binding (`[[services]] service = "<worker name>"`) to front auth
|
|
260
260
|
# same-origin via @auth-craft/pages-auth-proxy. The binding lives in the consumer's
|
|
261
261
|
# Pages project, not here — `serviceBindings` is a ready-to-paste snippet.
|
|
262
|
+
# Cost knobs, read back from the deployed stack so `outputs` reports what is live
|
|
263
|
+
# rather than what the caller's env happens to say.
|
|
264
|
+
local logRetention tracingMode
|
|
265
|
+
logRetention="$(cfn_output "$api_stack" LogRetentionDays)"
|
|
266
|
+
tracingMode="$(cfn_output "$api_stack" TracingMode)"
|
|
267
|
+
[[ -n "$logRetention" && "$logRetention" != "None" ]] || logRetention=""
|
|
268
|
+
[[ -n "$tracingMode" && "$tracingMode" != "None" ]] || tracingMode=""
|
|
269
|
+
|
|
262
270
|
local mode wnSystem wnTenant wnCustomer bindingService
|
|
263
271
|
mode="$(gateway_mode)"
|
|
264
272
|
if [[ "$mode" == "shared" ]]; then
|
|
@@ -288,11 +296,13 @@ print_outputs_json() {
|
|
|
288
296
|
--arg rpCustomer "${CF_WORKER_ROUTE_PREFIX_CUSTOMER:-/customer}" \
|
|
289
297
|
--arg wnSystem "$wnSystem" --arg wnTenant "$wnTenant" --arg wnCustomer "$wnCustomer" \
|
|
290
298
|
--arg bindingSnippet "$bindingSnippet" \
|
|
299
|
+
--arg logRetention "$logRetention" --arg tracingMode "$tracingMode" \
|
|
291
300
|
'{stage:$stage, project:$project, region:$region,
|
|
292
301
|
lambdaFunctionUrl:$url, apiBasePath:$base, authSystemName:$name,
|
|
293
302
|
dynamodbTable:$table, backendUrl:$backend,
|
|
294
303
|
jwtPublicKey:$jwtPub, jwtIssuer:$jwtIssuer, jwtAlgorithm:$jwtAlg,
|
|
295
304
|
serviceJwtPublicKey:$svcPub,
|
|
305
|
+
observability:{logRetentionDays:$logRetention, tracingMode:$tracingMode},
|
|
296
306
|
gatewayUrls:{system:$gwSystem, tenant:$gwTenant, customer:$gwCustomer},
|
|
297
307
|
workerNames:{system:$wnSystem, tenant:$wnTenant, customer:$wnCustomer},
|
|
298
308
|
routePrefixes:{system:$rpSystem, tenant:$rpTenant, customer:$rpCustomer},
|
package/lib/deploy-lambda.sh
CHANGED
|
@@ -25,6 +25,10 @@ deploy_lambda() {
|
|
|
25
25
|
OUTPUTS_FILE="${OUTPUTS_FILE:-$(mktemp -t auth-craft-cdk-outputs.XXXXXX.json)}"
|
|
26
26
|
export OUTPUTS_FILE
|
|
27
27
|
|
|
28
|
+
# Existing tables from older package versions may lack GSIs; CloudFormation adds at most
|
|
29
|
+
# one per update, so add them one at a time before the full deploy below.
|
|
30
|
+
sync_table_gsis
|
|
31
|
+
|
|
28
32
|
info "Deploying CDK stacks (${RESOURCE_NAME}-data, ${api_stack})…"
|
|
29
33
|
(
|
|
30
34
|
cd "$CDK_DIR"
|