Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
python-tests:
name: Python Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: pip install pytest boto3

- name: Run Lambda unit tests
run: pytest ztxb-aws-lab/tests/ -v

opa-tests:
name: OPA Policy Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install OPA
run: |
curl -L -o opa https://openpolicyagent.org/downloads/v0.64.1/opa_linux_amd64_static
chmod +x opa
sudo mv opa /usr/local/bin/

- name: Run Rego tests
run: opa test ztxb-aws-lab/app/pdp/policy/ -v

terraform-validate:
name: Terraform Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.5.0"

- name: Terraform Init
working-directory: ztxb-aws-lab/infra
run: terraform init -backend=false

- name: Terraform Validate
working-directory: ztxb-aws-lab/infra
run: terraform validate
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,6 @@ ztxb-aws-lab/infra/terraform.tfstate.backup

ztxb-aws-lab/infra/modules/api_notes/*.zip
ztxb-aws-lab/infra/modules/ztxp_broker/*.zip

# Claude Code
CLAUDE.md
136 changes: 119 additions & 17 deletions ztxb-aws-lab/app/lambdas/notes_api/handler.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,134 @@
# app/lambdas/notes_api/handler.py
"""
Notes API — a simple CRUD service backed by DynamoDB.

Only reachable after the PEP authorizer grants access. The
authorizer injects context (principalId, ztxp_decision) which
this handler uses to scope queries to the authenticated user.
"""
import json
import os
import logging
import uuid
from datetime import datetime, timezone

import boto3
from boto3.dynamodb.conditions import Key

logger = logging.getLogger()
logger.setLevel(logging.INFO)

TABLE_NAME = os.environ.get("TABLE_NAME", "unknown")
ddb = boto3.resource("dynamodb")
table = ddb.Table(TABLE_NAME)

def lambda_handler(event, context):
"""
Minimal stub Notes API.
Later we can wire this up to DynamoDB for real CRUD.
"""
logger.info("Received event: %s", json.dumps(event))

method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
path = event.get("rawPath", "/notes")

body = {
"message": "Notes API stub",
"method": method,
"path": path,
"table": TABLE_NAME,
}

def _response(status, body):
return {
"statusCode": 200,
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}


def _user_id(event):
"""Extract the authenticated user from the authorizer context."""
auth_ctx = event.get("requestContext", {}).get("authorizer", {}).get("lambda", {})
return auth_ctx.get("principalId", "anonymous")


def _note_id_from_path(event):
"""Extract note_id from path parameters (/notes/{note_id})."""
params = event.get("pathParameters") or {}
proxy = params.get("proxy", "")
return proxy.strip("/") if proxy else None


# ---------------------------------------------------------------------------
# CRUD operations
# ---------------------------------------------------------------------------

def list_notes(user_id):
resp = table.query(KeyConditionExpression=Key("user_id").eq(user_id))
return _response(200, {"notes": resp.get("Items", [])})


def get_note(user_id, note_id):
resp = table.get_item(Key={"user_id": user_id, "note_id": note_id})
item = resp.get("Item")
if not item:
return _response(404, {"error": "not_found"})
return _response(200, item)


def create_note(user_id, body):
note_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
item = {
"user_id": user_id,
"note_id": note_id,
"title": body.get("title", ""),
"content": body.get("content", ""),
"created_at": now,
"updated_at": now,
}
table.put_item(Item=item)
return _response(201, item)


def update_note(user_id, note_id, body):
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
resp = table.update_item(
Key={"user_id": user_id, "note_id": note_id},
UpdateExpression="SET title = :t, content = :c, updated_at = :u",
ExpressionAttributeValues={
":t": body.get("title", ""),
":c": body.get("content", ""),
":u": now,
},
ConditionExpression="attribute_exists(user_id)",
ReturnValues="ALL_NEW",
)
return _response(200, resp.get("Attributes", {}))


def delete_note(user_id, note_id):
table.delete_item(
Key={"user_id": user_id, "note_id": note_id},
ConditionExpression="attribute_exists(user_id)",
)
return _response(200, {"deleted": note_id})


# ---------------------------------------------------------------------------
# Lambda entry point
# ---------------------------------------------------------------------------

def lambda_handler(event, context):
logger.info("Notes API invoked")

method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
user_id = _user_id(event)
note_id = _note_id_from_path(event)

try:
body = json.loads(event.get("body") or "{}") if event.get("body") else {}
except json.JSONDecodeError:
return _response(400, {"error": "invalid_json"})

try:
if method == "GET" and not note_id:
return list_notes(user_id)
elif method == "GET" and note_id:
return get_note(user_id, note_id)
elif method == "POST":
return create_note(user_id, body)
elif method == "PUT" and note_id:
return update_note(user_id, note_id, body)
elif method == "DELETE" and note_id:
return delete_note(user_id, note_id)
else:
return _response(405, {"error": "method_not_allowed"})
except Exception as exc:
logger.error("Notes API error: %s", exc, exc_info=True)
return _response(500, {"error": "internal_error"})
Loading
Loading