@biffo/cli 0.38.0 → 0.41.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/_skeletons/plugin-template/.github/workflows/ci.yml +124 -0
- package/_skeletons/plugin-template/.github/workflows/release.yml +114 -0
- package/_skeletons/plugin-template/README.md +232 -0
- package/_skeletons/plugin-template/biffo.plugin.json +89 -0
- package/_skeletons/plugin-template/pyproject.toml +100 -0
- package/_skeletons/plugin-template/registry-schema.json +294 -0
- package/_skeletons/plugin-template/src/__init__.py +0 -0
- package/_skeletons/plugin-template/src/example_plugin/__init__.py +10 -0
- package/_skeletons/plugin-template/src/example_plugin/main.py +63 -0
- package/_skeletons/plugin-template/src/example_plugin/manifest.py +30 -0
- package/_skeletons/plugin-template/src/example_plugin/plugin.py +92 -0
- package/_skeletons/plugin-template/terraform/README.md +146 -0
- package/_skeletons/plugin-template/terraform/main.tf +167 -0
- package/_skeletons/plugin-template/terraform/outputs.tf +33 -0
- package/_skeletons/plugin-template/terraform/variables.tf +133 -0
- package/_skeletons/plugin-template/tests/conftest.py +17 -0
- package/_skeletons/plugin-template/tests/fakes.py +74 -0
- package/_skeletons/plugin-template/tests/test_example_plugin.py +108 -0
- package/_skeletons/registry/README.md +27 -0
- package/_skeletons/registry/plugins.json +5 -0
- package/_skeletons/registry/registry-schema.json +294 -0
- package/_skeletons/sibling-template/.github/renovate.json +38 -0
- package/_skeletons/sibling-template/.github/workflows/ci.yml +185 -0
- package/_skeletons/sibling-template/.github/workflows/codeql.yml +56 -0
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +219 -0
- package/_skeletons/sibling-template/.github/workflows/destroy-infra.yml +73 -0
- package/_skeletons/sibling-template/README.md +164 -0
- package/_skeletons/sibling-template/_gitignore +71 -0
- package/_skeletons/sibling-template/apps/frontend/.env.example +14 -0
- package/_skeletons/sibling-template/apps/frontend/eslint.config.mjs +10 -0
- package/_skeletons/sibling-template/apps/frontend/next.config.ts +20 -0
- package/_skeletons/sibling-template/apps/frontend/package.json +42 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-lock.yaml +5268 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-workspace.yaml +19 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/globals.css +34 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/layout.tsx +15 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/page.test.tsx +75 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/page.tsx +85 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/api-client.ts +58 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.test.ts +135 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.ts +87 -0
- package/_skeletons/sibling-template/apps/frontend/src/test-setup.ts +1 -0
- package/_skeletons/sibling-template/apps/frontend/tsconfig.json +29 -0
- package/_skeletons/sibling-template/apps/frontend/vitest.config.ts +18 -0
- package/_skeletons/sibling-template/biffo.sibling.json +7 -0
- package/_skeletons/sibling-template/infra/backend.tf +23 -0
- package/_skeletons/sibling-template/infra/main.tf +79 -0
- package/_skeletons/sibling-template/infra/outputs.tf +17 -0
- package/_skeletons/sibling-template/infra/variables.tf +63 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/main.tf +114 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/outputs.tf +12 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/variables.tf +41 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/main.tf +157 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/outputs.tf +4 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/variables.tf +70 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/main.tf +78 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/outputs.tf +4 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/variables.tf +12 -0
- package/_skeletons/sibling-template/services/api/pyproject.toml +69 -0
- package/_skeletons/sibling-template/services/api/src/api/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/config.py +31 -0
- package/_skeletons/sibling-template/services/api/src/api/core_client.py +64 -0
- package/_skeletons/sibling-template/services/api/src/api/main.py +43 -0
- package/_skeletons/sibling-template/services/api/src/api/middleware/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/middleware/auth.py +111 -0
- package/_skeletons/sibling-template/services/api/src/api/routers/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/routers/whoami.py +21 -0
- package/_skeletons/sibling-template/services/api/tests/conftest.py +24 -0
- package/_skeletons/sibling-template/services/api/tests/test_whoami.py +21 -0
- package/_skeletons/sibling-template/services/api/uv.lock +1160 -0
- package/core.version +1 -1
- package/dist/index.js +113 -87
- package/package.json +5 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
output "api_url" {
|
|
2
|
+
description = "This sibling's own API Gateway endpoint — set as NEXT_PUBLIC_API_URL in the frontend build."
|
|
3
|
+
value = module.api_gateway.api_endpoint
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
output "site_bucket_name" {
|
|
7
|
+
value = module.storage.site_bucket_name
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
output "site_bucket_regional_domain" {
|
|
11
|
+
description = "Consumed by the core project's registration PR — written into its siblings.auto.tfvars.json as this sibling's bucket_regional_domain (see modules/cloud/aws/cdn's sibling_origins in the core repo)."
|
|
12
|
+
value = module.storage.site_bucket_regional_domain
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
output "lambda_function_name" {
|
|
16
|
+
value = module.api.function_name
|
|
17
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
variable "project_name" {
|
|
2
|
+
description = "This sibling's name — also the path segment it's routed on (baseurl.com/<project_name>/*, ADR-0007). Must match the \"name\" this sibling was registered with in the core project's siblings.auto.tfvars.json."
|
|
3
|
+
type = string
|
|
4
|
+
|
|
5
|
+
validation {
|
|
6
|
+
condition = can(regex("^[a-z][a-z0-9-]*$", var.project_name))
|
|
7
|
+
error_message = "project_name must be lowercase alphanumeric with hyphens, starting with a letter (it becomes a URL path segment and an AWS resource name prefix)."
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
variable "environment" {
|
|
12
|
+
type = string
|
|
13
|
+
default = "dev"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
variable "aws_region" {
|
|
17
|
+
type = string
|
|
18
|
+
default = "us-east-1"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# ── Core project identity (ADR-0007) ───────────────────────────────────────
|
|
22
|
+
# This sibling never provisions its own Cognito pool or CloudFront
|
|
23
|
+
# distribution — it plugs into the core project's. These are plain
|
|
24
|
+
# pass-through input variables, not resources.
|
|
25
|
+
|
|
26
|
+
variable "core_cognito_user_pool_id" {
|
|
27
|
+
type = string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
variable "core_cognito_client_id" {
|
|
31
|
+
type = string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
variable "core_api_url" {
|
|
35
|
+
description = "Base URL of the core project's own API — the only place this sibling may read/write core-owned data (ADR-0002)."
|
|
36
|
+
type = string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
variable "core_portal_url" {
|
|
40
|
+
description = "Origin of the core portal (e.g. https://baseurl.com) — the frontend redirects here to sign in when there is no shared session."
|
|
41
|
+
type = string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
variable "cors_origins" {
|
|
45
|
+
description = "Origins allowed to call this sibling's API. Since the frontend is served path-routed on the SAME origin as the core portal, this is normally just [core_portal_url], plus http://localhost:3000 for local dev."
|
|
46
|
+
type = list(string)
|
|
47
|
+
default = ["http://localhost:3000"]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ── Two-phase CDN registration (see modules/cloud/aws/cdn's sibling_origins
|
|
51
|
+
# in the core project, and README.md's "Registering with the core project"
|
|
52
|
+
# section) ─────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
variable "parent_cloudfront_distribution_arn" {
|
|
55
|
+
description = "ARN of the core project's CloudFront distribution. Left empty until the core project's registration PR (opened by `biffo sibling create`) has merged and redeployed — the bucket policy below is skipped while empty, since there is no distribution ARN yet to trust. Re-apply with this set once that PR merges."
|
|
56
|
+
type = string
|
|
57
|
+
default = ""
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
variable "tags" {
|
|
61
|
+
type = map(string)
|
|
62
|
+
default = {}
|
|
63
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
terraform {
|
|
2
|
+
required_providers {
|
|
3
|
+
aws = { source = "hashicorp/aws", version = "~> 5.0" }
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
locals {
|
|
8
|
+
name_prefix = "${var.project_name}-${var.environment}"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
resource "aws_apigatewayv2_api" "main" {
|
|
12
|
+
name = "${local.name_prefix}-api"
|
|
13
|
+
protocol_type = "HTTP"
|
|
14
|
+
description = "Sibling API - ${local.name_prefix}"
|
|
15
|
+
tags = var.tags
|
|
16
|
+
|
|
17
|
+
# Applied by API Gateway itself, independent of the Lambda integration — this is
|
|
18
|
+
# what puts CORS headers on responses the JWT authorizer rejects before invoking
|
|
19
|
+
# Lambda (see the $default route below). FastAPI's CORSMiddleware only covers
|
|
20
|
+
# requests that actually reach the Lambda.
|
|
21
|
+
cors_configuration {
|
|
22
|
+
allow_origins = var.cors_origins
|
|
23
|
+
allow_methods = ["*"]
|
|
24
|
+
allow_headers = ["*"]
|
|
25
|
+
allow_credentials = true
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# JWT authorizer backed by the CORE project's Cognito pool (ADR-0007) —
|
|
30
|
+
# validates Bearer tokens on all protected routes. This is one of two layers
|
|
31
|
+
# of defense in depth: API Gateway rejects invalid/missing tokens before
|
|
32
|
+
# Lambda is ever invoked, and the Lambda's own auth middleware
|
|
33
|
+
# (services/api/src/api/middleware/auth.py) independently re-verifies the
|
|
34
|
+
# same token again.
|
|
35
|
+
resource "aws_apigatewayv2_authorizer" "cognito" {
|
|
36
|
+
api_id = aws_apigatewayv2_api.main.id
|
|
37
|
+
authorizer_type = "JWT"
|
|
38
|
+
identity_sources = ["$request.header.Authorization"]
|
|
39
|
+
name = "${local.name_prefix}-cognito"
|
|
40
|
+
|
|
41
|
+
jwt_configuration {
|
|
42
|
+
audience = [var.cognito_client_id]
|
|
43
|
+
issuer = "https://cognito-idp.${var.aws_region}.amazonaws.com/${var.cognito_user_pool_id}"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
resource "aws_apigatewayv2_integration" "lambda" {
|
|
48
|
+
api_id = aws_apigatewayv2_api.main.id
|
|
49
|
+
integration_type = "AWS_PROXY"
|
|
50
|
+
integration_uri = var.lambda_function_arn
|
|
51
|
+
payload_format_version = "2.0"
|
|
52
|
+
timeout_milliseconds = 29000
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Health check — no auth required (public endpoint)
|
|
56
|
+
resource "aws_apigatewayv2_route" "health" {
|
|
57
|
+
api_id = aws_apigatewayv2_api.main.id
|
|
58
|
+
route_key = "GET /api/v1/health"
|
|
59
|
+
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
|
|
60
|
+
authorization_type = "NONE"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# OPTIONS preflight — no auth so CORS preflight is never blocked by the JWT authorizer.
|
|
64
|
+
resource "aws_apigatewayv2_route" "options" {
|
|
65
|
+
api_id = aws_apigatewayv2_api.main.id
|
|
66
|
+
route_key = "OPTIONS /{proxy+}"
|
|
67
|
+
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Catch-all — all other routes require a valid Cognito JWT
|
|
71
|
+
resource "aws_apigatewayv2_route" "default" {
|
|
72
|
+
api_id = aws_apigatewayv2_api.main.id
|
|
73
|
+
route_key = "$default"
|
|
74
|
+
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
|
|
75
|
+
authorization_type = "JWT"
|
|
76
|
+
authorizer_id = aws_apigatewayv2_authorizer.cognito.id
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
resource "aws_apigatewayv2_stage" "main" {
|
|
80
|
+
api_id = aws_apigatewayv2_api.main.id
|
|
81
|
+
name = "$default"
|
|
82
|
+
auto_deploy = true
|
|
83
|
+
|
|
84
|
+
access_log_settings {
|
|
85
|
+
destination_arn = aws_cloudwatch_log_group.access_logs.arn
|
|
86
|
+
format = jsonencode({
|
|
87
|
+
requestId = "$context.requestId"
|
|
88
|
+
ip = "$context.identity.sourceIp"
|
|
89
|
+
requestTime = "$context.requestTime"
|
|
90
|
+
httpMethod = "$context.httpMethod"
|
|
91
|
+
routeKey = "$context.routeKey"
|
|
92
|
+
status = "$context.status"
|
|
93
|
+
protocol = "$context.protocol"
|
|
94
|
+
responseLength = "$context.responseLength"
|
|
95
|
+
integrationError = "$context.integrationErrorMessage"
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
tags = var.tags
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
resource "aws_cloudwatch_log_group" "access_logs" {
|
|
103
|
+
name = "/sibling/${local.name_prefix}/api-gateway"
|
|
104
|
+
retention_in_days = var.environment == "prod" ? 90 : 14
|
|
105
|
+
tags = var.tags
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
resource "aws_lambda_permission" "api_gateway" {
|
|
109
|
+
statement_id = "AllowAPIGatewayInvoke"
|
|
110
|
+
action = "lambda:InvokeFunction"
|
|
111
|
+
function_name = var.lambda_function_name
|
|
112
|
+
principal = "apigateway.amazonaws.com"
|
|
113
|
+
source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
|
|
114
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
output "api_id" {
|
|
2
|
+
value = aws_apigatewayv2_api.main.id
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
output "api_endpoint" {
|
|
6
|
+
description = "Base URL of the HTTP API — set as NEXT_PUBLIC_API_URL in the frontend build"
|
|
7
|
+
value = trimsuffix(aws_apigatewayv2_stage.main.invoke_url, "/")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
output "execution_arn" {
|
|
11
|
+
value = aws_apigatewayv2_api.main.execution_arn
|
|
12
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
variable "project_name" {
|
|
2
|
+
type = string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
variable "environment" {
|
|
6
|
+
type = string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
variable "lambda_function_arn" {
|
|
10
|
+
description = "ARN of the sibling API Lambda function to integrate"
|
|
11
|
+
type = string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
variable "lambda_function_name" {
|
|
15
|
+
description = "Name of the sibling API Lambda function (for the invoke permission)"
|
|
16
|
+
type = string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
variable "cognito_user_pool_id" {
|
|
20
|
+
description = "The CORE project's Cognito User Pool ID (ADR-0007) — this sibling has no pool of its own."
|
|
21
|
+
type = string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
variable "cognito_client_id" {
|
|
25
|
+
description = "The CORE project's Cognito App Client ID (ADR-0007) — same client as the core portal, so sessions are shared."
|
|
26
|
+
type = string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
variable "aws_region" {
|
|
30
|
+
type = string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
variable "cors_origins" {
|
|
34
|
+
description = "Origins allowed to call this API. Must be applied at the API Gateway level (not just FastAPI's CORSMiddleware) because the JWT authorizer rejects unauthenticated requests before they ever reach the Lambda, so no CORS headers would otherwise be present on 401s."
|
|
35
|
+
type = list(string)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
variable "tags" {
|
|
39
|
+
type = map(string)
|
|
40
|
+
default = {}
|
|
41
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
terraform {
|
|
2
|
+
required_providers {
|
|
3
|
+
aws = { source = "hashicorp/aws", version = "~> 5.0" }
|
|
4
|
+
archive = { source = "hashicorp/archive", version = "~> 2.0" }
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
# Minimal placeholder zip — Terraform creates this automatically during plan.
|
|
9
|
+
# The CI/CD pipeline overwrites the function code on every deploy;
|
|
10
|
+
# this only exists so the Lambda resource can be created on first apply.
|
|
11
|
+
data "archive_file" "placeholder" {
|
|
12
|
+
type = "zip"
|
|
13
|
+
output_path = "${path.module}/placeholder.zip"
|
|
14
|
+
source {
|
|
15
|
+
content = "def handler(event, context):\n pass\n"
|
|
16
|
+
filename = "handler.py"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
locals {
|
|
21
|
+
name_prefix = "${var.project_name}-${var.environment}"
|
|
22
|
+
function_name = "${local.name_prefix}-${var.function_name}"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Dead letter queue for failed invocations — encrypted at rest with KMS
|
|
26
|
+
resource "aws_sqs_queue" "dlq" {
|
|
27
|
+
name = "${local.function_name}-dlq"
|
|
28
|
+
message_retention_seconds = 1209600 # 14 days
|
|
29
|
+
kms_master_key_id = var.sqs_kms_key_id
|
|
30
|
+
tags = var.tags
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Security group — only created when this function is attached to a VPC
|
|
34
|
+
# (var.enable_vpc_access). A sibling with no DB of its own (ADR-0002/
|
|
35
|
+
# ADR-0007) should generally leave this false.
|
|
36
|
+
resource "aws_security_group" "lambda" {
|
|
37
|
+
count = var.enable_vpc_access ? 1 : 0
|
|
38
|
+
name = "${local.function_name}-sg"
|
|
39
|
+
description = "Lambda function security group for ${local.function_name}"
|
|
40
|
+
vpc_id = var.vpc_id
|
|
41
|
+
|
|
42
|
+
egress {
|
|
43
|
+
from_port = 0
|
|
44
|
+
to_port = 0
|
|
45
|
+
protocol = "-1"
|
|
46
|
+
cidr_blocks = ["0.0.0.0/0"]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
tags = merge(var.tags, { Name = "${local.function_name}-sg" })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
resource "aws_cloudwatch_log_group" "function" {
|
|
53
|
+
name = "/aws/lambda/${local.function_name}"
|
|
54
|
+
retention_in_days = 365 # 1 year — satisfies CKV_AWS_338
|
|
55
|
+
kms_key_id = var.cloudwatch_kms_key_id
|
|
56
|
+
tags = var.tags
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Least-privilege execution role — no AdministratorAccess, no PowerUser
|
|
60
|
+
data "aws_iam_policy_document" "lambda_trust" {
|
|
61
|
+
statement {
|
|
62
|
+
effect = "Allow"
|
|
63
|
+
actions = ["sts:AssumeRole"]
|
|
64
|
+
principals {
|
|
65
|
+
type = "Service"
|
|
66
|
+
identifiers = ["lambda.amazonaws.com"]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
resource "aws_iam_role" "lambda" {
|
|
72
|
+
name = "${local.function_name}-role"
|
|
73
|
+
assume_role_policy = data.aws_iam_policy_document.lambda_trust.json
|
|
74
|
+
tags = var.tags
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
resource "aws_iam_role_policy_attachment" "vpc_access" {
|
|
78
|
+
count = var.enable_vpc_access ? 1 : 0
|
|
79
|
+
role = aws_iam_role.lambda.name
|
|
80
|
+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
data "aws_iam_policy_document" "lambda_permissions" {
|
|
84
|
+
statement {
|
|
85
|
+
sid = "CloudWatchLogs"
|
|
86
|
+
effect = "Allow"
|
|
87
|
+
actions = [
|
|
88
|
+
"logs:CreateLogStream",
|
|
89
|
+
"logs:PutLogEvents"
|
|
90
|
+
]
|
|
91
|
+
resources = ["${aws_cloudwatch_log_group.function.arn}:*"]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
statement {
|
|
95
|
+
sid = "DLQAccess"
|
|
96
|
+
effect = "Allow"
|
|
97
|
+
actions = ["sqs:SendMessage"]
|
|
98
|
+
resources = [aws_sqs_queue.dlq.arn]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
resource "aws_iam_role_policy" "lambda" {
|
|
103
|
+
name = "sibling-lambda-policy"
|
|
104
|
+
role = aws_iam_role.lambda.id
|
|
105
|
+
policy = data.aws_iam_policy_document.lambda_permissions.json
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
resource "aws_lambda_function" "main" {
|
|
109
|
+
function_name = local.function_name
|
|
110
|
+
role = aws_iam_role.lambda.arn
|
|
111
|
+
handler = var.handler
|
|
112
|
+
runtime = var.runtime
|
|
113
|
+
memory_size = var.memory_size
|
|
114
|
+
timeout = var.timeout
|
|
115
|
+
|
|
116
|
+
filename = data.archive_file.placeholder.output_path
|
|
117
|
+
source_code_hash = data.archive_file.placeholder.output_base64sha256
|
|
118
|
+
|
|
119
|
+
dead_letter_config {
|
|
120
|
+
target_arn = aws_sqs_queue.dlq.arn
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
dynamic "vpc_config" {
|
|
124
|
+
for_each = var.enable_vpc_access ? [1] : []
|
|
125
|
+
content {
|
|
126
|
+
subnet_ids = var.private_subnet_ids
|
|
127
|
+
security_group_ids = [aws_security_group.lambda[0].id]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
environment {
|
|
132
|
+
variables = merge(
|
|
133
|
+
var.environment_variables,
|
|
134
|
+
{
|
|
135
|
+
POWERTOOLS_SERVICE_NAME = local.function_name
|
|
136
|
+
POWERTOOLS_LOG_LEVEL = var.environment == "prod" ? "WARNING" : "INFO"
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
tracing_config {
|
|
142
|
+
mode = "Active"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
depends_on = [
|
|
146
|
+
aws_cloudwatch_log_group.function,
|
|
147
|
+
aws_iam_role_policy_attachment.vpc_access,
|
|
148
|
+
aws_iam_role_policy.lambda,
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
lifecycle {
|
|
152
|
+
# Code is managed by the CI/CD pipeline — Terraform only manages config
|
|
153
|
+
ignore_changes = [filename, source_code_hash]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
tags = var.tags
|
|
157
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
variable "project_name" {
|
|
2
|
+
type = string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
variable "environment" {
|
|
6
|
+
type = string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
variable "function_name" {
|
|
10
|
+
type = string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
variable "handler" {
|
|
14
|
+
type = string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
variable "runtime" {
|
|
18
|
+
type = string
|
|
19
|
+
default = "python3.13"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
variable "enable_vpc_access" {
|
|
23
|
+
description = "Attach this Lambda's ENIs to a VPC. Defaults to false — a sibling with no database of its own (ADR-0002/ADR-0007) gets nothing from VPC attachment, and in NAT-less networking configs it would cut off outbound internet access including calls to the core project's API."
|
|
24
|
+
type = bool
|
|
25
|
+
default = false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
variable "vpc_id" {
|
|
29
|
+
description = "VPC to attach this Lambda's ENIs to. Only used when enable_vpc_access is true."
|
|
30
|
+
type = string
|
|
31
|
+
default = ""
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
variable "private_subnet_ids" {
|
|
35
|
+
description = "Private subnets to place ENIs in. Only used when enable_vpc_access is true."
|
|
36
|
+
type = list(string)
|
|
37
|
+
default = []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
variable "memory_size" {
|
|
41
|
+
type = number
|
|
42
|
+
default = 512
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
variable "timeout" {
|
|
46
|
+
type = number
|
|
47
|
+
default = 30
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
variable "environment_variables" {
|
|
51
|
+
type = map(string)
|
|
52
|
+
default = {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
variable "tags" {
|
|
56
|
+
type = map(string)
|
|
57
|
+
default = {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
variable "sqs_kms_key_id" {
|
|
61
|
+
description = "KMS key ID for SQS queue encryption (CKV_AWS_27). Leave empty for AWS-owned key."
|
|
62
|
+
type = string
|
|
63
|
+
default = ""
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
variable "cloudwatch_kms_key_id" {
|
|
67
|
+
description = "KMS key ID for CloudWatch log group encryption (CKV_AWS_158). Leave empty for AWS-owned key."
|
|
68
|
+
type = string
|
|
69
|
+
default = ""
|
|
70
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
terraform {
|
|
2
|
+
required_providers {
|
|
3
|
+
aws = { source = "hashicorp/aws", version = "~> 5.0" }
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
data "aws_caller_identity" "current" {}
|
|
8
|
+
|
|
9
|
+
locals {
|
|
10
|
+
name_prefix = "${var.project_name}-${var.environment}"
|
|
11
|
+
site_bucket = "${local.name_prefix}-site-${data.aws_caller_identity.current.account_id}"
|
|
12
|
+
logs_bucket = "${local.name_prefix}-logs-${data.aws_caller_identity.current.account_id}"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Access logs bucket
|
|
16
|
+
resource "aws_s3_bucket" "logs" {
|
|
17
|
+
bucket = local.logs_bucket
|
|
18
|
+
force_destroy = true
|
|
19
|
+
tags = var.tags
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
resource "aws_s3_bucket_public_access_block" "logs" {
|
|
23
|
+
bucket = aws_s3_bucket.logs.id
|
|
24
|
+
block_public_acls = true
|
|
25
|
+
block_public_policy = true
|
|
26
|
+
ignore_public_acls = true
|
|
27
|
+
restrict_public_buckets = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
|
|
31
|
+
bucket = aws_s3_bucket.logs.id
|
|
32
|
+
rule {
|
|
33
|
+
apply_server_side_encryption_by_default {
|
|
34
|
+
sse_algorithm = "AES256"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# This sibling's static frontend export — served via the PARENT project's
|
|
40
|
+
# CloudFront distribution as a new origin/ordered_cache_behavior (ADR-0007;
|
|
41
|
+
# see modules/cloud/aws/cdn/main.tf in the parent repo). The bucket policy
|
|
42
|
+
# trusting that distribution's ARN is deliberately NOT in this module — it
|
|
43
|
+
# lives in infra/main.tf, parameterized by var.parent_cloudfront_distribution_arn,
|
|
44
|
+
# because this module has no way to know that ARN (it belongs to a
|
|
45
|
+
# distribution this repo doesn't own or create).
|
|
46
|
+
resource "aws_s3_bucket" "site" {
|
|
47
|
+
bucket = local.site_bucket
|
|
48
|
+
force_destroy = true
|
|
49
|
+
tags = var.tags
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
resource "aws_s3_bucket_public_access_block" "site" {
|
|
53
|
+
bucket = aws_s3_bucket.site.id
|
|
54
|
+
block_public_acls = true
|
|
55
|
+
block_public_policy = true
|
|
56
|
+
ignore_public_acls = true
|
|
57
|
+
restrict_public_buckets = true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
resource "aws_s3_bucket_versioning" "site" {
|
|
61
|
+
bucket = aws_s3_bucket.site.id
|
|
62
|
+
versioning_configuration { status = "Enabled" }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
|
|
66
|
+
bucket = aws_s3_bucket.site.id
|
|
67
|
+
rule {
|
|
68
|
+
apply_server_side_encryption_by_default {
|
|
69
|
+
sse_algorithm = "AES256"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
resource "aws_s3_bucket_logging" "site" {
|
|
75
|
+
bucket = aws_s3_bucket.site.id
|
|
76
|
+
target_bucket = aws_s3_bucket.logs.id
|
|
77
|
+
target_prefix = "site-access-logs/"
|
|
78
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
output "site_bucket_name" { value = aws_s3_bucket.site.bucket }
|
|
2
|
+
output "site_bucket_arn" { value = aws_s3_bucket.site.arn }
|
|
3
|
+
output "site_bucket_regional_domain" { value = aws_s3_bucket.site.bucket_regional_domain_name }
|
|
4
|
+
output "logs_bucket_name" { value = aws_s3_bucket.logs.bucket }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sibling-api"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
description = "Sibling app backend — verifies the shared core Cognito JWT and calls the core API for all data (ADR-0002/ADR-0007). No database client of any kind belongs in this service."
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"fastapi>=0.115.6",
|
|
8
|
+
"mangum>=0.19.0",
|
|
9
|
+
"pydantic>=2.10.4",
|
|
10
|
+
"pydantic-settings>=2.7.1",
|
|
11
|
+
"aws-lambda-powertools[tracer]>=3.4.0",
|
|
12
|
+
"pyjwt[crypto]>=2.10.0",
|
|
13
|
+
"httpx>=0.28.1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[dependency-groups]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=8.3.4",
|
|
19
|
+
"pytest-asyncio>=0.25.2",
|
|
20
|
+
"pytest-cov>=6.0.0",
|
|
21
|
+
"bandit>=1.8.0",
|
|
22
|
+
"pip-audit>=2.8.0",
|
|
23
|
+
"ruff>=0.8.0",
|
|
24
|
+
"pyright>=1.1.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/api"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
target-version = "py313"
|
|
36
|
+
line-length = 100
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "N", "W", "B", "S", "UP", "ANN"]
|
|
40
|
+
ignore = [
|
|
41
|
+
"S101", # allow assert in tests
|
|
42
|
+
"B008", # FastAPI's Depends(...)/Security(...) default-argument pattern is
|
|
43
|
+
# the sanctioned idiom (see FastAPI's own docs) — not the mutable-default
|
|
44
|
+
# footgun this rule otherwise guards against.
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.per-file-ignores]
|
|
48
|
+
"tests/**/*.py" = ["S101", "ANN"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.format]
|
|
51
|
+
quote-style = "double"
|
|
52
|
+
indent-style = "space"
|
|
53
|
+
|
|
54
|
+
[tool.pyright]
|
|
55
|
+
pythonVersion = "3.13"
|
|
56
|
+
typeCheckingMode = "standard"
|
|
57
|
+
venvPath = "."
|
|
58
|
+
venv = ".venv"
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests"]
|
|
62
|
+
python_files = ["test_*.py", "*_test.py"]
|
|
63
|
+
python_classes = ["Test*"]
|
|
64
|
+
python_functions = ["test_*"]
|
|
65
|
+
asyncio_mode = "auto"
|
|
66
|
+
|
|
67
|
+
[tool.coverage.run]
|
|
68
|
+
source = ["src"]
|
|
69
|
+
omit = ["*/tests/*"]
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Settings(BaseSettings):
|
|
5
|
+
# Change this prefix to your own sibling's name if you want a more
|
|
6
|
+
# specific env var namespace — just keep it consistent with
|
|
7
|
+
# infra/main.tf's environment_variables block.
|
|
8
|
+
model_config = SettingsConfigDict(env_prefix="SIBLING_", case_sensitive=False)
|
|
9
|
+
|
|
10
|
+
# Cognito — deliberately the CORE project's pool/client, not a new one
|
|
11
|
+
# (ADR-0007): this sibling verifies the same JWT the core portal issued,
|
|
12
|
+
# so users get single sign-on across the core and every sibling for free.
|
|
13
|
+
cognito_user_pool_id: str = ""
|
|
14
|
+
cognito_client_id: str = ""
|
|
15
|
+
cognito_region: str = "us-east-1"
|
|
16
|
+
# Pre-loaded JWKS JSON string — set by Terraform at apply time in no-NAT
|
|
17
|
+
# dev environments so the Lambda can verify JWTs without an outbound
|
|
18
|
+
# call. If empty, the JWKS is fetched at runtime (requires NAT or a
|
|
19
|
+
# cognito-idp VPC endpoint).
|
|
20
|
+
cognito_jwks_json: str = ""
|
|
21
|
+
|
|
22
|
+
# The core project's own API — the ONLY place this service is allowed to
|
|
23
|
+
# get or persist data (ADR-0002). Never add a database client here.
|
|
24
|
+
core_api_url: str = ""
|
|
25
|
+
|
|
26
|
+
environment: str = "dev"
|
|
27
|
+
log_level: str = "INFO"
|
|
28
|
+
cors_origins: list[str] = ["http://localhost:3000"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
settings = Settings()
|