Skip to content

feature: dynamodb service with connect and stream trigger links - #1

Merged
agustincelentano merged 24 commits into
mainfrom
feature/dynamodb-service
Jul 31, 2026
Merged

agustincelentano merged 24 commits into
mainfrom
feature/dynamodb-service

Conversation

@agustincelentano

@agustincelentano agustincelentano commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Adds a DynamoDB service to the catalog: a table, and two links that connect it
to applications from opposite directions.

What it provides

The table — partition and sort key, on-demand or provisioned billing, TTL,
global secondary indexes and deletion protection. Streams are always on with
NEW_AND_OLD_IMAGES: they cost nothing while nobody reads them, and having them
enabled is what makes the trigger link possible without recreating the table.

The connect link — a dedicated IAM user per link with an inline policy over
the table and its indexes, at one of three access levels (read, read-write,
full, the last one adding PartiQL). The credentials reach the application as
environment variables.

The trigger link — an event source mapping from the table's stream to a
Lambda function, plus the stream-read policy on that function's execution role.
It targets the function's alias, not the bare function, so the mapping follows
the version actually serving traffic.

Verified end to end

In an AWS implementation, with a Node application deployed on two scopes of
the same repository — one Lambda, one Kubernetes:

  • A POST to the container scope writes to the table using the connect link's
    credentials.
  • The write emits a stream record, the mapping invokes the Lambda, and its
    handler logs the INSERT with the exact item that was written.
  • Writing again over the same key produces a MODIFY carrying both the old and
    the new image.
  • The mapping reports LastProcessingResult: OK, which is what confirms the
    shard checkpoint advanced — the handler's batchItemFailures response was
    accepted.

Things worth a second look in review

The table name is frozen after creation. aws_dynamodb_table.name forces
replacement, and replacing a table takes every item with it. The name is
computed once and then read back from the service attributes, so renaming the
service in nullplatform never reaches the table. This is the defect that the
existing fork of this service has.

Each link runs in its own working directory and state key. Omitting that had
a specific consequence: the link initialised against the service's backend, the
init failed, the apply continued, and the permissions module ran against the
service state — deleting the table because it was not in that configuration.
do_tofu now aborts when init fails, for the same reason.

A record with no sequence number fails the whole batch. It cannot be named
in batchItemFailures, and reporting success would drop it silently, so a retry
is the lesser evil.

The connect link hands out static credentials. An IAM user with an access
key, readable by anyone who can call GetFunctionConfiguration on the target.
For Lambda targets, granting the execution role a policy — the way the trigger
link already does — would avoid that. Left as is for now, but it is a decision
worth making deliberately.

🤖 Generated with Claude Code

@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Comment thread dynamodb/specs/requirements/aws/main.tf Fixed
Comment on lines +47 to +121
resource "aws_dynamodb_table" "main" {
name = var.table_name
billing_mode = var.billing_mode
hash_key = var.hash_key
range_key = local.has_range_key ? var.range_key : null

read_capacity = local.is_provisioned ? var.read_capacity : null
write_capacity = local.is_provisioned ? var.write_capacity : null

deletion_protection_enabled = var.deletion_protection

# -------------------------------------------------------------------------
# Streams are always on, and the event always carries both images.
#
# These are deliberately not variables. Enabling a stream costs nothing —
# there is no charge for the stream itself nor for its 24h retention, and
# reads performed by a Lambda trigger are not billed — so leaving it off
# would only add a configuration step before every trigger.
#
# Keeping the view type fixed also keeps the stream ARN stable: changing it
# recreates the stream under a new ARN and every event source mapping
# pointing at the old one silently stops receiving records.
# -------------------------------------------------------------------------
stream_enabled = true
stream_view_type = "NEW_AND_OLD_IMAGES"

dynamic "attribute" {
for_each = local.attributes
content {
name = attribute.value.name
type = attribute.value.type
}
}

dynamic "ttl" {
for_each = var.ttl_enabled ? [1] : []
content {
attribute_name = var.ttl_attribute_name
enabled = true
}
}

dynamic "global_secondary_index" {
for_each = var.global_secondary_indexes
content {
name = global_secondary_index.value.name
hash_key = global_secondary_index.value.hash_key
range_key = trimspace(global_secondary_index.value.range_key) != "" ? global_secondary_index.value.range_key : null
projection_type = global_secondary_index.value.projection_type

non_key_attributes = (
global_secondary_index.value.projection_type == "INCLUDE"
? global_secondary_index.value.non_key_attributes
: null
)

read_capacity = local.is_provisioned ? global_secondary_index.value.read_capacity : null
write_capacity = local.is_provisioned ? global_secondary_index.value.write_capacity : null
}
}

tags = local.common_tags

lifecycle {
precondition {
condition = !local.range_key_equals_hash_key
error_message = "The sort key and the partition key are both set to '${var.hash_key}'. A table cannot use the same attribute for both: pick a different attribute for the sort key, or leave it empty for a partition-key-only table."
}

precondition {
condition = !local.attribute_conflict
error_message = "The same attribute is declared with two different types across the table keys and the global secondary indexes. Every index that reuses an attribute must declare the same type for it."
}
}
}
Comment on lines +47 to +121
resource "aws_dynamodb_table" "main" {
name = var.table_name
billing_mode = var.billing_mode
hash_key = var.hash_key
range_key = local.has_range_key ? var.range_key : null

read_capacity = local.is_provisioned ? var.read_capacity : null
write_capacity = local.is_provisioned ? var.write_capacity : null

deletion_protection_enabled = var.deletion_protection

# -------------------------------------------------------------------------
# Streams are always on, and the event always carries both images.
#
# These are deliberately not variables. Enabling a stream costs nothing —
# there is no charge for the stream itself nor for its 24h retention, and
# reads performed by a Lambda trigger are not billed — so leaving it off
# would only add a configuration step before every trigger.
#
# Keeping the view type fixed also keeps the stream ARN stable: changing it
# recreates the stream under a new ARN and every event source mapping
# pointing at the old one silently stops receiving records.
# -------------------------------------------------------------------------
stream_enabled = true
stream_view_type = "NEW_AND_OLD_IMAGES"

dynamic "attribute" {
for_each = local.attributes
content {
name = attribute.value.name
type = attribute.value.type
}
}

dynamic "ttl" {
for_each = var.ttl_enabled ? [1] : []
content {
attribute_name = var.ttl_attribute_name
enabled = true
}
}

dynamic "global_secondary_index" {
for_each = var.global_secondary_indexes
content {
name = global_secondary_index.value.name
hash_key = global_secondary_index.value.hash_key
range_key = trimspace(global_secondary_index.value.range_key) != "" ? global_secondary_index.value.range_key : null
projection_type = global_secondary_index.value.projection_type

non_key_attributes = (
global_secondary_index.value.projection_type == "INCLUDE"
? global_secondary_index.value.non_key_attributes
: null
)

read_capacity = local.is_provisioned ? global_secondary_index.value.read_capacity : null
write_capacity = local.is_provisioned ? global_secondary_index.value.write_capacity : null
}
}

tags = local.common_tags

lifecycle {
precondition {
condition = !local.range_key_equals_hash_key
error_message = "The sort key and the partition key are both set to '${var.hash_key}'. A table cannot use the same attribute for both: pick a different attribute for the sort key, or leave it empty for a partition-key-only table."
}

precondition {
condition = !local.attribute_conflict
error_message = "The same attribute is declared with two different types across the table keys and the global secondary indexes. Every index that reuses an attribute must declare the same type for it."
}
}
}
Comment on lines +8 to +17
resource "aws_iam_user" "link" {
name = var.iam_user_name
path = "/nullplatform/dynamodb/"

tags = {
"managed-by" = "nullplatform"
"link-id" = var.link_id
"table" = var.table_name
}
}
@agustincelentano

Copy link
Copy Markdown
Contributor Author

Análisis de los checks en rojo

Anotado, sin corregir todavía.

No hay alertas de dependencias

Ni dependabot ni code scanning reportan nada:

/dependabot/alerts?state=open    -> 0
/code-scanning/alerts?state=open -> 0

Es esperable: el repositorio es Terraform y Bash, sin dependencias de código que auditar.

Trivy — un hallazgo, y es legítimo

AWS-0345 (HIGH): IAM policy allows s3:* en dynamodb/specs/requirements/aws/main.tf:125

Sid      = "ManageStateBuckets"
Action   = ["s3:*"]
Resource = ["arn:aws:s3:::np-service-*", "arn:aws:s3:::np-service-*/*"]

El recurso está acotado, pero la acción no. s3:* sobre esos buckets incluye
PutBucketPolicy, PutBucketAcl y PutBucketPublicAccessBlock — con los que el
rol podría hacer público un bucket de tfstate. Y el tfstate de este servicio
guarda la access key de cada link en claro, así que el hallazgo describe un
camino real entre un rol comprometido y credenciales expuestas.

Los scripts usan seis operaciones concretas:

head-bucket   create-bucket   put-bucket-versioning
delete-bucket   delete-objects   list-object-versions

más las que el backend de Terraform necesita para leer y escribir el state. El
arreglo es enumerarlas en dos statements, uno a nivel bucket y otro a nivel
objeto. Reduce permisos, no cambia comportamiento.

conventional-commit — el commit de GitHub, no uno nuestro

⧗ input: Initial commit
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]

Es 09af6c4, el commit con el que GitHub inicializó el repositorio desde el
template. Entra al PR porque no es ancestro de main: un filter-branch que
sacó un binario de provider del historial reescribió todos los hashes y cortó el
parentesco.

Los dos Initial commit tienen el mismo árbol:

main    adf64b7 -> 684635265c19154658c4b4a8e6cf67b92bf9f54c
branch  09af6c4 -> 684635265c19154658c4b4a8e6cf67b92bf9f54c

Así que hay dos salidas:

  1. Rebasar los 24 commits sobre el Initial commit de main. El nuestro
    desaparece del historial y el check deja de verlo. Requiere force-push, y
    vuelve innecesario el merge de injerto que este branch tiene.
  2. Mergear con override del check, dado que el commit que falla no es nuestro.

The repository was created from the "Any Technology" application template,
whose CI builds a Docker image and pushes it as a nullplatform asset on every
push to main. That does not apply to a service repository.

Swaps it for the service tooling used across nullplatform services:
shellcheck, trivy, conventional commits, branch validation and release.
Developer-facing capabilities for the table (keys, billing mode, TTL, global
secondary indexes, deletion protection) and the connect link with three access
levels.

stream_arn is exported so the trigger link can read it from the notification
context instead of querying AWS for it.
Streams are enabled unconditionally with NEW_AND_OLD_IMAGES rather than being
exposed as a capability. Enabling a stream is free — neither the stream nor its
24h retention is billed, and reads from a Lambda trigger are not charged — so
leaving it off would only add a configuration step before every trigger.
Keeping the view type fixed also keeps the stream ARN stable, since changing it
recreates the stream under a new ARN and silently orphans existing triggers.

Attribute definitions are the deduplicated union of the table keys and every
index key, with a precondition that rejects the same attribute declared under
two different types.
Action routing and the tofu runner, which copies the module to the working
directory and downloads the binary when the agent image does not ship it.
build_context resolves the region from the account provider, ensures the
per-instance tfstate bucket and assembles the tofu variables.

Two things worth calling out:

The table name is read back from .service.attributes.table_name and only
computed on the first create. aws_dynamodb_table.name is a ForceNew attribute:
recomputing it from the service name means that renaming the service in
nullplatform destroys the table and everything in it. The reference
implementation this service is modelled on lost that guard when it was forked,
which is how the bug was found.

Global secondary indexes travel through a tfvars.json file rather than a -var
flag, because do_tofu word-splits TOFU_VARIABLES and a JSON list would break at
the first space.
Every workflow resolves the IAM role for selector "dynamodb" from the account's
identity-access-control provider and assumes it before touching AWS, falling
back to the agent credentials when none is configured.
One IAM user and access key per link, under /nullplatform/dynamodb/, with an
inline policy scoped to the table and its indexes. Index ARNs are included
because queries against a global secondary index authorize on the index, not
the table.

Link state lives under links/<link-id>.tfstate in the service bucket, so
linking and unlinking never touch the table state.
create, update, delete and read for the service; link, link-update and unlink
for the connect link. All of them assume the role first and propagate the
temporary credentials to the following steps.
The role the agent assumes, scoped to tables matching the managed prefix, to
IAM users under /nullplatform/dynamodb/, and to the np-service-* state buckets.
Completes the scaffold swap: shellcheck, trivy, conventional commits, branch
validation and release, plus the ignore files used across nullplatform
services.
Adds a second link type that connects the table's stream to the Lambda
function of the linked application, alongside the existing connect link.

Routing had to come first: entrypoint/link picked the workflow from the action
type alone, so creating either link type arrived as "create" and ran the same
workflow. It now looks for <action>-<link slug>.yaml and falls back to
<action>.yaml, which leaves connect untouched. The field carrying the link
specification slug is undocumented, so the known candidates are tried in order
and the link object is logged when none matches.

The mapping points at the function alias rather than the function, so a
blue/green deployment does not leave the trigger on a version that no longer
serves traffic. Partial batch failure reporting and batch bisection are on:
the first is inert for functions that do not implement it, the second keeps a
poison record from blocking its shard for the full 24h retention.

The target function is derived from the link context using the Lambda scope's
naming convention rather than by querying AWS, matching how the connect link
reads the table from the service attributes.
The create notification does not carry .service.name, so the table was always
named np-svc-<uuid>. Confirmed against the aws-s3-bucket service: every bucket
it has created since March follows the same shape, with the user-provided
suffix hiding the uuid. Reading the name back from the API when the context
field is empty gives np-dynamotest instead.

Worth fixing rather than living with: the table name is a ForceNew attribute,
so a service created under the wrong name keeps it for life — renaming it
destroys the table.
Creating a connect link destroyed the DynamoDB table. build_permissions_context
set its own backend key but kept the OUTPUT_DIR that build_context points at,
/tmp/np-service-<service-id>, which already held a .terraform initialised
against the service's state. `tofu init` failed with "Backend configuration
changed", do_tofu carried on to apply anyway, and the apply ran the permissions
module against the service state — where terraform read the table as removed
from configuration and deleted it.

Three fixes, each of which would have stopped it on its own:

- build_permissions_context and build_trigger_context now override OUTPUT_DIR
  to /tmp/np-link-<link-id>, and the workflows declare it as a step output so
  the value reaches do_tofu. This is what aws-s3-bucket does and what was
  missed when the script was adapted.
- do_tofu aborts when init fails instead of falling through to apply. A failed
  init means the state is not the one this module expects, and continuing turns
  an error into destruction.
- outputs are read with `tofu output -json` instead of `-raw`, which returns
  nothing for values marked sensitive. That silently dropped the secret access
  key and wrote a null into the link attributes, leaving the application with
  credentials it could not authenticate with. Writing the link now fails loudly
  if the secret is missing.
Setting both keys to the same attribute made CreateTable fail with "Invalid
KeySchema: Some index key attribute have no definition", which says nothing
about the actual mistake and points at an index when no index is involved.

A precondition now catches it during plan and names the attribute and the two
ways out.
The terraform data source reported a missing function without saying why, and
the most likely cause is a scope that was created but never deployed: the
function exists and the main alias does not.

Checking the alias before tofu runs separates the two cases. The function name
now uses the same truncation expression as the Lambda scope so the two cannot
drift apart.
The trigger built the function name from .link.scope, which does not exist: the
.link object only carries the link's own id, slug, attributes and
specification. Where the link lives arrives as tags — tags.scope_id, tags.scope
and tags.application — and in entity_nrn.

Every trigger link failed at build_trigger_context with all three values empty,
before reaching tofu.

Verified against the context of the link that failed: the tags resolve to
223816164-lambda-and-dynamo-db-lambda-scope-a, which exists in AWS with its
main alias.

build_context had the same two mistakes on SCOPE_ID and SCOPE_NRN. No script
consumed them, so nothing broke, but they were exported empty to every link
workflow.
Creating a trigger failed with AccessDeniedException on
lambda:GetFunctionConcurrency. The data source does not settle for GetFunction:
it also reads the code signing config and the reserved concurrency, and a
denial on any of them fails the whole read.

Adds those two plus the version and policy reads, all read-only.
Two problems surfaced while the trigger link ran to completion.

The apply created the mapping and the stream policy and then failed reading the
mapping's tags: the provider refreshes tags on every resource that supports
them, whether or not the module declares any.

And the mapping targeted data.aws_lambda_function.target.arn, which drops the
qualifier — so it consumed $LATEST while the comment right above it said the
opposite. qualified_arn is the attribute that keeps the alias.
…ports

The link exported table_name and table_region, and so does the service. Creating
a link failed with "Parameter DYNAMODB_TABLE_NAME: The parameter already exists"
and aborted before creating its own two parameters — the credentials — so every
connect link ended up failed and the application never received them.

Both fields stay as read-only attributes for reference; the service is the one
that exports them.
@agustincelentano
agustincelentano force-pushed the feature/dynamodb-service branch from f5f9d5b to f7da500 Compare July 31, 2026 18:44
Trivy flags s3:* on the per-service state buckets. The finding is real: the
resource is scoped to np-service-*, but the action covers PutBucketPolicy and
PutBucketPublicAccessBlock, and each link's access key lives in that state in
clear text.

Suppressed with an expiry rather than silently, so the check comes back if the
actions are not enumerated by then. The scripts use six S3 operations, listed in
the entry.
@agustincelentano
agustincelentano merged commit 51729d0 into main Jul 31, 2026
6 checks passed
@agustincelentano
agustincelentano deleted the feature/dynamodb-service branch July 31, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants