diff --git a/Makefile b/Makefile index e91005df..e73630f2 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ test-api: ./api-test/tester -expectedStatus=401 REQUEST GET $(URL)/lpas/$(LPA_UID) '' && \ cat ./docs/example-lpa.json | ./api-test/tester -jwtSecret=$(JWT_SECRET_KEY) -expectedStatus=201 REQUEST PUT $(URL)/lpas/$(LPA_UID) "`xargs -0`" && \ ./api-test/tester -jwtSecret=$(JWT_SECRET_KEY) -expectedStatus=400 REQUEST PUT $(URL)/lpas/$(LPA_UID) '{"version":"2"}' && \ - ./api-test/tester -jwtSecret=$(JWT_SECRET_KEY) -expectedStatus=201 REQUEST POST $(URL)/lpas/$(LPA_UID)/updates '{"type":"CHANGE_NAME","changes":[{"key":"/donor/surname","old":"Zoller","new":"Kjar"}]}' && \ + cat ./docs/certificate-provider-change.json | ./api-test/tester -jwtSecret=$(JWT_SECRET_KEY) -expectedStatus=201 REQUEST POST $(URL)/lpas/$(LPA_UID)/updates "`xargs -0`" && \ ./api-test/tester -jwtSecret=$(JWT_SECRET_KEY) -expectedStatus=200 REQUEST GET $(URL)/lpas/$(LPA_UID) '' .PHONY: test-api @@ -41,11 +41,11 @@ create-tables: --key-schema AttributeName=uid,KeyType=HASH \ --billing-mode PAY_PER_REQUEST - docker compose run --rm aws dynamodb describe-table --table-name events || \ + docker compose run --rm aws dynamodb describe-table --table-name changes || \ docker compose run --rm aws dynamodb create-table \ - --table-name events \ - --attribute-definitions AttributeName=uid,AttributeType=S AttributeName=created,AttributeType=S \ - --key-schema AttributeName=uid,KeyType=HASH AttributeName=created,KeyType=RANGE \ + --table-name changes \ + --attribute-definitions AttributeName=uid,AttributeType=S AttributeName=applied,AttributeType=S \ + --key-schema AttributeName=uid,KeyType=HASH AttributeName=applied,KeyType=RANGE \ --billing-mode PAY_PER_REQUEST run-structurizr: diff --git a/docker-compose.yml b/docker-compose.yml index be56f5fc..c55def4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,9 @@ version: "3.6" services: ddb: image: amazon/dynamodb-local:latest + command: -jar DynamoDBLocal.jar -sharedDb -dbPath . + ports: + - "8000:8000" lambda-create: depends_on: [ddb] @@ -17,6 +20,7 @@ services: AWS_ACCESS_KEY_ID: X AWS_SECRET_ACCESS_KEY: X DDB_TABLE_NAME_DEEDS: deeds + DDB_TABLE_NAME_CHANGES: changes JWT_SECRET_KEY: ${JWT_SECRET_KEY:-secret} volumes: - "./lambda/.aws-lambda-rie:/aws-lambda" @@ -35,6 +39,7 @@ services: AWS_ACCESS_KEY_ID: X AWS_SECRET_ACCESS_KEY: X DDB_TABLE_NAME_DEEDS: deeds + DDB_TABLE_NAME_CHANGES: changes JWT_SECRET_KEY: ${JWT_SECRET_KEY:-secret} volumes: - "./lambda/.aws-lambda-rie:/aws-lambda" @@ -53,6 +58,7 @@ services: AWS_ACCESS_KEY_ID: X AWS_SECRET_ACCESS_KEY: X DDB_TABLE_NAME_DEEDS: deeds + DDB_TABLE_NAME_CHANGES: changes JWT_SECRET_KEY: ${JWT_SECRET_KEY:-secret} volumes: - "./lambda/.aws-lambda-rie:/aws-lambda" diff --git a/docs/certificate-provider-change.json b/docs/certificate-provider-change.json new file mode 100644 index 00000000..1fda75a5 --- /dev/null +++ b/docs/certificate-provider-change.json @@ -0,0 +1,30 @@ +{ + "type": "CERTIFICATE_PROVIDER_SIGN", + "changes": [ + { + "key": "/certificateProvider/address/line1", + "new": "98 CVVVV", + "old": null + }, + { + "key": "/certificateProvider/address/town", + "new": "Murkkkk Town", + "old": null + }, + { + "key": "/certificateProvider/address/country", + "new": "GB", + "old": null + }, + { + "key": "/certificateProvider/signedAt", + "new": "2024-01-13T22:00:00Z", + "old": null + }, + { + "key": "/certificateProvider/contactLanguagePreference", + "new": "en", + "old": null + } + ] +} diff --git a/docs/example-lpa.json b/docs/example-lpa.json index 3d13da01..b34ba1a0 100644 --- a/docs/example-lpa.json +++ b/docs/example-lpa.json @@ -1,42 +1,43 @@ { + "lpaType": "personal-welfare", "donor": { - "firstNames": "Homer", - "surname": "Zoller", - "dateOfBirth": "1960-04-06", - "email": "h.zoller@example.com", + "firstNames": "Feeg", + "lastName": "Bundlaaaa", "address": { - "line1": "79 Bury Rd", - "town": "Hampton Lovett", - "postcode": "WR9 2PF", + "line1": "74 Cloob Close", + "town": "Mahhhhhhhhhh", "country": "GB" - } + }, + "dateOfBirth": "1970-01-24", + "email": "nobody@not.a.real.domain" }, "attorneys": [ { - "firstNames": "Jake", - "surname": "Vallar", - "dateOfBirth": "2001-01-17", - "email": "jval5@example.com", - "status": "active", - "address": { - "line1": "71 South Western Terrace", - "town": "Milton", - "postcode": "6306", - "country": "AU" - } - }, - { - "firstNames": "Tory", - "surname": "Lapolla", - "dateOfBirth": "1989-12-29", - "status": "replacement", + "firstNames": "Herman", + "lastName": "Seakrest", "address": { - "line1": "184 Bayside Apartments", - "line2": "East Street", - "line3": "Saratosa", - "town": "Polebrook", - "country": "US" - } + "line1": "81 NighOnTimeWeBuiltIt Street", + "town": "Mahhhhhhhhhh", + "country": "GB" + }, + "dateOfBirth": "1982-07-24", + "email": "nobody2@not.a.real.domain", + "status": "active" } - ] + ], + "certificateProvider": { + "firstNames": "Vone", + "lastName": "Spust", + "address": { + "line1": "122111 Zonnington Way", + "town": "Mahhhhhhhhhh", + "country": "GB" + }, + "channel": "online", + "email": "nobody3@not.a.real.domain" + }, + "lifeSustainingTreatmentOption": "option-a", + "signedAt": "2024-01-10T23:00:00Z", + "certificateProviderNotRelatedConfirmedAt": "2024-01-11T22:00:00Z" } + diff --git a/go.mod b/go.mod index 18301a3f..88cc78a8 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.27.0 github.com/aws/aws-xray-sdk-go v1.8.3 github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/google/go-cmp v0.5.9 github.com/google/uuid v1.6.0 github.com/ministryofjustice/opg-go-common v0.0.0-20231128145056-24628fba649c github.com/stretchr/testify v1.8.4 diff --git a/go.sum b/go.sum index fec7bcb3..54688de8 100644 --- a/go.sum +++ b/go.sum @@ -2,38 +2,14 @@ github.com/DATA-DOG/go-sqlmock v1.4.1 h1:ThlnYciV1iM/V0OSF/dtkqWb6xo5qITT1TJBG1M github.com/DATA-DOG/go-sqlmock v1.4.1/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/aws/aws-lambda-go v1.43.0 h1:Tdu7SnMB5bD+CbdnSq1Dg4sM68vEuGIDcQFZ+IjUfx0= -github.com/aws/aws-lambda-go v1.43.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/aws/aws-lambda-go v1.44.0 h1:Xp9PANXKsSJ23IhE4ths592uWTCEewswPhSH9qpAuQQ= -github.com/aws/aws-lambda-go v1.44.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/aws/aws-lambda-go v1.45.0 h1:3xS35Dlc8ffmcwfcKTyqJGiMuL0UDvkQaVUrI5yHycI= -github.com/aws/aws-lambda-go v1.45.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-lambda-go v1.46.0 h1:UWVnvh2h2gecOlFhHQfIPQcD8pL/f7pVCutmFl+oXU8= github.com/aws/aws-lambda-go v1.46.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/aws/aws-sdk-go v1.49.13 h1:f4mGztsgnx2dR9r8FQYa9YW/RsKb+N7bgef4UGrOW1Y= -github.com/aws/aws-sdk-go v1.49.13/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.49.15 h1:aH9bSV4kL4ziH0AMtuYbukGIVebXddXBL0cKZ1zj15k= -github.com/aws/aws-sdk-go v1.49.15/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.49.16 h1:KAQwhLg296hfffRdh+itA9p7Nx/3cXS/qOa3uF9ssig= -github.com/aws/aws-sdk-go v1.49.16/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.49.17 h1:Cc+7LgPjKeJkF2SdNo1IkpQ5Dfl9HCZEVw9OP3CPuEI= -github.com/aws/aws-sdk-go v1.49.17/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.49.21 h1:Rl8KW6HqkwzhATwvXhyr7vD4JFUMi7oXGAw9SrxxIFY= -github.com/aws/aws-sdk-go v1.49.21/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.50.5 h1:H2Aadcgwr7a2aqS6ZwcE+l1mA6ZrTseYCvjw2QLmxIA= -github.com/aws/aws-sdk-go v1.50.5/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= -github.com/aws/aws-sdk-go v1.50.6 h1:FaXvNwHG3Ri1paUEW16Ahk9zLVqSAdqa1M3phjZR35Q= -github.com/aws/aws-sdk-go v1.50.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go v1.50.9 h1:yX66aKnEtRc/uNV/1EH8CudRT5aLwVwcSwTBphuVPt8= github.com/aws/aws-sdk-go v1.50.9/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= -github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.12.14 h1:FpgWcv1aqU3xXbMVwEBr2sCeRT1Cctwqg/sWMI4wLoo= -github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.12.14/go.mod h1:J2zgl/oFM9OWQoaEATWvh426859hrB1cuVEqLgGpi+Q= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.12.16 h1:KZvXflfyoL43jhDe2tDHPeK9C+edHJl2Rb07N7Dq3qY= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.12.16/go.mod h1:SdkjT6MneWbTztIxA5cZ8QTvD4ASCeM7IhUkIIhvVa0= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.26.8 h1:XKO0BswTDeZMLDBd/b5pCEZGttNXrzRUVtFvp2Ak/Vo= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.26.8/go.mod h1:N5tqZcYMM0N1PN7UQYJNWuGyO886OfnMhf/3MAbqMcI= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.27.0 h1:e/HPLjLas04wKnmCUSSXD44cYdVjT/Dcd9CkmlYNyNU= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.27.0/go.mod h1:N5tqZcYMM0N1PN7UQYJNWuGyO886OfnMhf/3MAbqMcI= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.18.7 h1:srShyROqxzC7p18Ws8mqM2sqxJO/8L3Kpiqf+NboJLg= @@ -54,8 +30,6 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= diff --git a/internal/ddb/client.go b/internal/ddb/client.go index 0f539291..fe130916 100644 --- a/internal/ddb/client.go +++ b/internal/ddb/client.go @@ -12,8 +12,48 @@ import ( ) type Client struct { - ddb *dynamodb.DynamoDB - tableName string + ddb *dynamodb.DynamoDB + tableName string + changesTableName string +} + +func (c *Client) PutChanges(ctx context.Context, data any, update shared.Update) error { + changesItem, err := dynamodbattribute.MarshalMap(map[string]interface{}{ + "uid": update.Uid, + "applied": update.Applied, + "author": update.Author, + "type": update.Type, + "change": update.Changes, + }) + + item, err := dynamodbattribute.MarshalMap(data) + if err != nil { + return err + } + + transactInput := &dynamodb.TransactWriteItemsInput{ + TransactItems: []*dynamodb.TransactWriteItem{ + // write the LPA to the deeds table + &dynamodb.TransactWriteItem{ + Put: &dynamodb.Put{ + TableName: aws.String(c.tableName), + Item: item, + }, + }, + + // record the change + &dynamodb.TransactWriteItem{ + Put: &dynamodb.Put{ + TableName: aws.String(c.changesTableName), + Item: changesItem, + }, + }, + }, + } + + _, err = c.ddb.TransactWriteItemsWithContext(ctx, transactInput) + + return err } func (c *Client) Put(ctx context.Context, data any) error { @@ -54,13 +94,14 @@ func (c *Client) Get(ctx context.Context, uid string) (shared.Lpa, error) { return lpa, err } -func New(endpoint, tableName string) *Client { +func New(endpoint, tableName string, changesTableName string) *Client { sess := session.Must(session.NewSession()) sess.Config.Endpoint = &endpoint c := &Client{ - ddb: dynamodb.New(sess), - tableName: tableName, + ddb: dynamodb.New(sess), + tableName: tableName, + changesTableName: changesTableName, } xray.AWS(c.ddb.Client) diff --git a/internal/shared/jwt.go b/internal/shared/jwt.go index 38d28d85..ebf033b2 100644 --- a/internal/shared/jwt.go +++ b/internal/shared/jwt.go @@ -21,12 +21,12 @@ var validIssuers []string = []string{ mrlpa, } -type lpaStoreClaims struct { +type LpaStoreClaims struct { jwt.RegisteredClaims } // note that default validation for RegisteredClaims checks exp is in the future -func (l lpaStoreClaims) Validate() error { +func (l LpaStoreClaims) Validate() error { // validate issued at (iat) iat, err := l.GetIssuedAt() if err != nil { @@ -92,36 +92,37 @@ var bearerRegexp = regexp.MustCompile("^Bearer[ ]+") // verify JWT from event header // returns true if verified, false otherwise -func (v JWTVerifier) VerifyHeader(event events.APIGatewayProxyRequest) bool { +func (v JWTVerifier) VerifyHeader(event events.APIGatewayProxyRequest) (*LpaStoreClaims, error) { jwtHeaders := GetEventHeader("X-Jwt-Authorization", event) if len(jwtHeaders) < 1 { - return false + return nil, fmt.Errorf("Invalid X-Jwt-Authorization header") } tokenStr := bearerRegexp.ReplaceAllString(jwtHeaders[0], "") - if v.verifyToken(tokenStr) != nil { - return false + claims, err := v.verifyToken(tokenStr) + if err != nil { + return nil, err } - return true + return claims, nil } // tokenStr is the JWT token, minus any "Bearer: " prefix -func (v JWTVerifier) verifyToken(tokenStr string) error { - lsc := lpaStoreClaims{} +func (v JWTVerifier) verifyToken(tokenStr string) (*LpaStoreClaims, error) { + lsc := LpaStoreClaims{} parsedToken, err := jwt.ParseWithClaims(tokenStr, &lsc, func(token *jwt.Token) (interface{}, error) { return v.secretKey, nil }) if err != nil { - return err + return nil, err } if !parsedToken.Valid { - return fmt.Errorf("Invalid JWT") + return nil, fmt.Errorf("Invalid JWT") } - return nil + return &lsc, nil } diff --git a/internal/shared/jwt_test.go b/internal/shared/jwt_test.go index 8077da3d..9424f8e9 100644 --- a/internal/shared/jwt_test.go +++ b/internal/shared/jwt_test.go @@ -26,7 +26,7 @@ func createToken(claims jwt.MapClaims) string { } func TestVerifyEmptyJwt(t *testing.T) { - err := verifier.verifyToken("") + _, err := verifier.verifyToken("") assert.NotNil(t, err) } @@ -38,7 +38,7 @@ func TestVerifyExpInPast(t *testing.T) { "sub": "M-3467-89QW-ERTY", }) - err := verifier.verifyToken(token) + _, err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -54,7 +54,7 @@ func TestVerifyIatInFuture(t *testing.T) { "sub": "someone@someplace.somewhere.com", }) - err := verifier.verifyToken(token) + _, err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -70,7 +70,7 @@ func TestVerifyIssuer(t *testing.T) { "sub": "someone@someplace.somewhere.com", }) - err := verifier.verifyToken(token) + _, err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -86,7 +86,7 @@ func TestVerifyBadEmailForSiriusIssuer(t *testing.T) { "sub": "", }) - err := verifier.verifyToken(token) + _, err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -102,7 +102,7 @@ func TestVerifyBadUIDForMRLPAIssuer(t *testing.T) { "sub": "", }) - err := verifier.verifyToken(token) + _, err := verifier.verifyToken(token) assert.NotNil(t, err) if err != nil { @@ -118,7 +118,7 @@ func TestVerifyGoodJwt(t *testing.T) { "sub": "someone@someplace.somewhere.com", }) - err := verifier.verifyToken(token) + _, err := verifier.verifyToken(token) assert.Nil(t, err) } @@ -134,7 +134,7 @@ func TestNewJWTVerifier(t *testing.T) { newVerifier := NewJWTVerifier() os.Unsetenv("JWT_SECRET_KEY") - err := newVerifier.verifyToken(token) + _, err := newVerifier.verifyToken(token) assert.Nil(t, err) } @@ -143,8 +143,8 @@ func TestVerifyHeaderNoJWTHeader(t *testing.T) { MultiValueHeaders: map[string][]string{}, } - verified := verifier.VerifyHeader(event) - assert.False(t, verified) + _, err := verifier.VerifyHeader(event) + assert.NotNil(t, err) } func TestVerifyHeader(t *testing.T) { @@ -163,6 +163,6 @@ func TestVerifyHeader(t *testing.T) { }, } - verified := verifier.VerifyHeader(event) - assert.True(t, verified) + _, err := verifier.VerifyHeader(event) + assert.Nil(t, err) } diff --git a/internal/shared/update.go b/internal/shared/update.go index 536028bf..2eefd4d8 100644 --- a/internal/shared/update.go +++ b/internal/shared/update.go @@ -9,6 +9,10 @@ type Change struct { } type Update struct { + Id string `json:"id"` // UUID for the update + Uid string `json:"uid"` // UID of the changed LPA + Applied string `json:"applied"` // RFC3339 datetime + Author string `json:"author"` Type string `json:"type"` Changes []Change `json:"changes"` } diff --git a/lambda/create/main.go b/lambda/create/main.go index ceb66965..607635a3 100644 --- a/lambda/create/main.go +++ b/lambda/create/main.go @@ -22,14 +22,19 @@ type Store interface { Get(ctx context.Context, uid string) (shared.Lpa, error) } +type Verifier interface { + VerifyHeader(events.APIGatewayProxyRequest) (*shared.LpaStoreClaims, error) +} + type Lambda struct { store Store - verifier shared.JWTVerifier + verifier Verifier logger Logger } func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - if !l.verifier.VerifyHeader(event) { + _, err := l.verifier.VerifyHeader(event) + if err != nil { l.logger.Print("Unable to verify JWT from header") return shared.ProblemUnauthorisedRequest.Respond() } @@ -46,7 +51,7 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe // check for existing Lpa var existingLpa shared.Lpa - existingLpa, err := l.store.Get(ctx, uid) + existingLpa, err = l.store.Get(ctx, uid) if err != nil { l.logger.Print(err) return shared.ProblemInternalServerError.Respond() @@ -95,7 +100,11 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe func main() { l := &Lambda{ - store: ddb.New(os.Getenv("AWS_DYNAMODB_ENDPOINT"), os.Getenv("DDB_TABLE_NAME_DEEDS")), + store: ddb.New( + os.Getenv("AWS_DYNAMODB_ENDPOINT"), + os.Getenv("DDB_TABLE_NAME_DEEDS"), + os.Getenv("DDB_TABLE_NAME_CHANGES"), + ), verifier: shared.NewJWTVerifier(), logger: logging.New(os.Stdout, "opg-data-lpa-store"), } diff --git a/lambda/get/main.go b/lambda/get/main.go index d5b8c1bd..f0cf203d 100644 --- a/lambda/get/main.go +++ b/lambda/get/main.go @@ -20,14 +20,19 @@ type Store interface { Get(ctx context.Context, uid string) (shared.Lpa, error) } +type Verifier interface { + VerifyHeader(events.APIGatewayProxyRequest) (*shared.LpaStoreClaims, error) +} + type Lambda struct { store Store - verifier shared.JWTVerifier + verifier Verifier logger Logger } func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - if !l.verifier.VerifyHeader(event) { + _, err := l.verifier.VerifyHeader(event) + if err != nil { l.logger.Print("Unable to verify JWT from header") return shared.ProblemUnauthorisedRequest.Respond() } @@ -68,7 +73,11 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe func main() { l := &Lambda{ - store: ddb.New(os.Getenv("AWS_DYNAMODB_ENDPOINT"), os.Getenv("DDB_TABLE_NAME_DEEDS")), + store: ddb.New( + os.Getenv("AWS_DYNAMODB_ENDPOINT"), + os.Getenv("DDB_TABLE_NAME_DEEDS"), + os.Getenv("DDB_TABLE_NAME_CHANGES"), + ), verifier: shared.NewJWTVerifier(), logger: logging.New(os.Stdout, "opg-data-lpa-store"), } diff --git a/lambda/update/main.go b/lambda/update/main.go index 7759d478..814c1817 100644 --- a/lambda/update/main.go +++ b/lambda/update/main.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "os" + "time" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" + "github.com/google/uuid" "github.com/ministryofjustice/opg-data-lpa-store/internal/ddb" "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" "github.com/ministryofjustice/opg-go-common/logging" @@ -17,12 +19,12 @@ type Logger interface { } type Store interface { - Put(ctx context.Context, data any) error + PutChanges(ctx context.Context, data any, update shared.Update) error Get(ctx context.Context, uid string) (shared.Lpa, error) } type Verifier interface { - VerifyHeader(events.APIGatewayProxyRequest) bool + VerifyHeader(events.APIGatewayProxyRequest) (*shared.LpaStoreClaims, error) } type Lambda struct { @@ -32,7 +34,8 @@ type Lambda struct { } func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - if !l.verifier.VerifyHeader(event) { + claims, err := l.verifier.VerifyHeader(event) + if err != nil { l.logger.Print("Unable to verify JWT from header") return shared.ProblemUnauthorisedRequest.Respond() } @@ -45,7 +48,7 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe } var update shared.Update - if err := json.Unmarshal([]byte(event.Body), &update); err != nil { + if err = json.Unmarshal([]byte(event.Body), &update); err != nil { l.logger.Print(err) return shared.ProblemInternalServerError.Respond() } @@ -75,7 +78,12 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe return problem.Respond() } - if err := l.store.Put(ctx, lpa); err != nil { + update.Id = uuid.NewString() + update.Uid = lpa.Uid + update.Applied = time.Now().Format(time.RFC3339) + update.Author, _ = claims.GetSubject() + + if err := l.store.PutChanges(ctx, lpa, update); err != nil { l.logger.Print(err) return shared.ProblemInternalServerError.Respond() } @@ -94,7 +102,11 @@ func (l *Lambda) HandleEvent(ctx context.Context, event events.APIGatewayProxyRe func main() { l := &Lambda{ - store: ddb.New(os.Getenv("AWS_DYNAMODB_ENDPOINT"), os.Getenv("DDB_TABLE_NAME_DEEDS")), + store: ddb.New( + os.Getenv("AWS_DYNAMODB_ENDPOINT"), + os.Getenv("DDB_TABLE_NAME_DEEDS"), + os.Getenv("DDB_TABLE_NAME_CHANGES"), + ), verifier: shared.NewJWTVerifier(), logger: logging.New(os.Stdout, "opg-data-lpa-store"), } diff --git a/lambda/update/main_test.go b/lambda/update/main_test.go index 68475e3a..dacb9259 100644 --- a/lambda/update/main_test.go +++ b/lambda/update/main_test.go @@ -2,12 +2,18 @@ package main import ( "context" + "encoding/json" "errors" "io" + "regexp" "testing" "time" "github.com/aws/aws-lambda-go/events" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/golang-jwt/jwt/v5" "github.com/ministryofjustice/opg-data-lpa-store/internal/shared" "github.com/ministryofjustice/opg-go-common/logging" "github.com/stretchr/testify/assert" @@ -20,6 +26,7 @@ type mockStore struct { getErr error put any putErr error + update shared.Update } func (m *mockStore) Get(context.Context, string) (shared.Lpa, error) { return m.get, m.getErr } @@ -27,22 +34,39 @@ func (m *mockStore) Put(ctx context.Context, data any) error { m.put = data return m.putErr } +func (m *mockStore) PutChanges(ctx context.Context, data any, update shared.Update) error { + m.put = data + m.update = update + return m.putErr +} -type mockVerifier struct{ ok bool } +type mockVerifier struct { + claims shared.LpaStoreClaims + err error +} -func (m *mockVerifier) VerifyHeader(events.APIGatewayProxyRequest) bool { return m.ok } +func (m *mockVerifier) VerifyHeader(events.APIGatewayProxyRequest) (*shared.LpaStoreClaims, error) { + return &m.claims, m.err +} func TestHandleEvent(t *testing.T) { store := &mockStore{get: shared.Lpa{Uid: "1"}} l := Lambda{ store: store, - verifier: &mockVerifier{ok: true}, + verifier: &mockVerifier{ + claims: shared.LpaStoreClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "1234", + }, + }, + }, logger: logging.New(io.Discard, ""), } resp, err := l.HandleEvent(context.Background(), events.APIGatewayProxyRequest{ Body: `{"type":"CERTIFICATE_PROVIDER_SIGN","changes":[{"key":"/certificateProvider/signedAt","old":null,"new":"2022-01-02T12:13:14.000000006Z"},{"key":"/certificateProvider/contactLanguagePreference","old":null,"new":"en"}]}`, }) + assert.Nil(t, err) assert.Equal(t, 201, resp.StatusCode) assert.Contains(t, resp.Body, `"2022-01-02T12:13:14.000000006Z"`) @@ -56,12 +80,37 @@ func TestHandleEvent(t *testing.T) { }, }, }, store.put) + + assert.NoError(t, uuid.Validate(store.update.Id)) + assert.Regexp(t, regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`), store.update.Applied) + + assert.True(t, cmp.Equal( + shared.Update{ + Uid: "1", + Author: "1234", + Type: "CERTIFICATE_PROVIDER_SIGN", + Changes: []shared.Change{ + shared.Change{ + Key: "/certificateProvider/signedAt", + Old: json.RawMessage(`null`), + New: json.RawMessage(`"2022-01-02T12:13:14.000000006Z"`), + }, + shared.Change{ + Key: "/certificateProvider/contactLanguagePreference", + Old: json.RawMessage(`null`), + New: json.RawMessage(`"en"`), + }, + }, + }, + store.update, + cmpopts.IgnoreFields(shared.Update{}, "Id", "Applied"), + )) } func TestHandleEventWhenUnknownType(t *testing.T) { l := Lambda{ store: &mockStore{get: shared.Lpa{Uid: "1"}}, - verifier: &mockVerifier{ok: true}, + verifier: &mockVerifier{}, logger: logging.New(io.Discard, ""), } @@ -76,7 +125,7 @@ func TestHandleEventWhenUnknownType(t *testing.T) { func TestHandleEventWhenUpdateInvalid(t *testing.T) { l := Lambda{ store: &mockStore{get: shared.Lpa{Uid: "1"}}, - verifier: &mockVerifier{ok: true}, + verifier: &mockVerifier{}, logger: logging.New(io.Discard, ""), } @@ -91,7 +140,7 @@ func TestHandleEventWhenUpdateInvalid(t *testing.T) { func TestHandleEventWhenLpaNotFound(t *testing.T) { l := Lambda{ store: &mockStore{}, - verifier: &mockVerifier{ok: true}, + verifier: &mockVerifier{}, logger: logging.New(io.Discard, ""), } @@ -106,7 +155,7 @@ func TestHandleEventWhenLpaNotFound(t *testing.T) { func TestHandleEventWhenStoreGetError(t *testing.T) { l := Lambda{ store: &mockStore{getErr: expectedError}, - verifier: &mockVerifier{ok: true}, + verifier: &mockVerifier{}, logger: logging.New(io.Discard, ""), } @@ -120,7 +169,7 @@ func TestHandleEventWhenStoreGetError(t *testing.T) { func TestHandleEventWhenRequestBodyNotJSON(t *testing.T) { l := Lambda{ - verifier: &mockVerifier{ok: true}, + verifier: &mockVerifier{}, logger: logging.New(io.Discard, ""), } @@ -132,7 +181,7 @@ func TestHandleEventWhenRequestBodyNotJSON(t *testing.T) { func TestHandleEventWhenHeaderNotVerified(t *testing.T) { l := Lambda{ - verifier: &mockVerifier{ok: false}, + verifier: &mockVerifier{err: errors.New("Invalid JWT")}, logger: logging.New(io.Discard, ""), } diff --git a/terraform/environment/dynamodb.tf b/terraform/environment/dynamodb.tf index 07f4be74..998506ff 100644 --- a/terraform/environment/dynamodb.tf +++ b/terraform/environment/dynamodb.tf @@ -31,3 +31,44 @@ resource "aws_dynamodb_table_replica" "deeds_table" { point_in_time_recovery = true provider = aws.eu_west_2 } + +resource "aws_dynamodb_table" "changes_table" { + name = "changes-${local.environment_name}" + billing_mode = "PAY_PER_REQUEST" + deletion_protection_enabled = local.environment.is_production + stream_enabled = true + stream_view_type = "NEW_AND_OLD_IMAGES" + hash_key = "uid" + + server_side_encryption { + enabled = true + } + + attribute { + name = "uid" + type = "S" + } + + range_key = "applied" + + attribute { + name = "applied" + type = "S" + } + + point_in_time_recovery { + enabled = true + } + + lifecycle { + ignore_changes = [replica] + } + + provider = aws.eu_west_1 +} + +resource "aws_dynamodb_table_replica" "changes_table" { + global_table_arn = aws_dynamodb_table.changes_table.arn + point_in_time_recovery = true + provider = aws.eu_west_2 +} diff --git a/terraform/environment/region/main.tf b/terraform/environment/region/main.tf index 458b2bc9..5cf739e6 100644 --- a/terraform/environment/region/main.tf +++ b/terraform/environment/region/main.tf @@ -17,6 +17,7 @@ module "lambda" { environment_variables = { DDB_TABLE_NAME_DEEDS = var.dynamodb_name + DDB_TABLE_NAME_CHANGES = var.dynamodb_name_changes JWT_SECRET_KEY = "secret" } diff --git a/terraform/environment/region/variables.tf b/terraform/environment/region/variables.tf index f3bcc27e..c2956ec8 100644 --- a/terraform/environment/region/variables.tf +++ b/terraform/environment/region/variables.tf @@ -18,6 +18,16 @@ variable "dynamodb_name" { type = string } +variable "dynamodb_arn_changes" { + description = "ARN of DynamoDB table for changes" + type = string +} + +variable "dynamodb_name_changes" { + description = "Name of DynamoDB table for changes" + type = string +} + variable "allowed_arns" { description = "List of external ARNs allowed to access the API Gateway" type = list(string) diff --git a/terraform/environment/regions.tf b/terraform/environment/regions.tf index 95d966f7..c549f8f9 100644 --- a/terraform/environment/regions.tf +++ b/terraform/environment/regions.tf @@ -1,12 +1,14 @@ module "eu_west_1" { source = "./region" - app_version = var.app_version - dynamodb_arn = aws_dynamodb_table.deeds_table.arn - dynamodb_name = aws_dynamodb_table.deeds_table.name - environment_name = local.environment_name - allowed_arns = local.environment.allowed_arns - dns_weighting = 100 + app_version = var.app_version + dynamodb_arn = aws_dynamodb_table.deeds_table.arn + dynamodb_name = aws_dynamodb_table.deeds_table.name + dynamodb_arn_changes = aws_dynamodb_table.changes_table.arn + dynamodb_name_changes = aws_dynamodb_table.changes_table.name + environment_name = local.environment_name + allowed_arns = local.environment.allowed_arns + dns_weighting = 100 providers = { aws.region = aws.eu_west_1 @@ -17,12 +19,14 @@ module "eu_west_1" { module "eu_west_2" { source = "./region" - app_version = var.app_version - dynamodb_arn = aws_dynamodb_table_replica.deeds_table.arn - dynamodb_name = aws_dynamodb_table.deeds_table.name - environment_name = local.environment_name - allowed_arns = local.environment.allowed_arns - dns_weighting = 0 + app_version = var.app_version + dynamodb_arn = aws_dynamodb_table_replica.deeds_table.arn + dynamodb_name = aws_dynamodb_table.deeds_table.name + dynamodb_arn_changes = aws_dynamodb_table_replica.changes_table.arn + dynamodb_name_changes = aws_dynamodb_table.changes_table.name + environment_name = local.environment_name + allowed_arns = local.environment.allowed_arns + dns_weighting = 0 providers = { aws.region = aws.eu_west_2