Skip to content

Commit eb6c9b7

Browse files
committed
feat: add secret expiration tracking
1 parent ba89551 commit eb6c9b7

15 files changed

Lines changed: 998 additions & 14 deletions

lambda/go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ go 1.26
44

55
require (
66
github.com/aws/aws-lambda-go v1.52.0
7-
github.com/aws/aws-sdk-go-v2 v1.41.5
7+
github.com/aws/aws-sdk-go-v2 v1.41.7
88
github.com/aws/aws-sdk-go-v2/config v1.32.10
99
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
10+
github.com/aws/aws-sdk-go-v2/service/scheduler v1.17.24
1011
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.2
1112
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.1
12-
github.com/aws/smithy-go v1.24.2
13+
github.com/aws/smithy-go v1.25.1
1314
github.com/getsops/sops/v3 v3.12.1
1415
github.com/gkampitakis/go-snaps v0.5.19
1516
github.com/invopop/jsonschema v0.13.0
@@ -46,8 +47,8 @@ require (
4647
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect
4748
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
4849
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.3 // indirect
49-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
50-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
50+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
51+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
5152
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
5253
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
5354
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect

lambda/go.sum

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi
6969
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
7070
github.com/aws/aws-lambda-go v1.52.0 h1:5NfiRaVl9FafUIt2Ld/Bv22kT371mfAI+l1Hd+tV7ZE=
7171
github.com/aws/aws-lambda-go v1.52.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A=
72-
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
73-
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
72+
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
73+
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
7474
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
7575
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
7676
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
@@ -81,10 +81,10 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6
8181
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
8282
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.3 h1:+mQ8NQBh7B7c2FBtppRnwkrmuwFON1XQQ+5yblomZKk=
8383
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.3/go.mod h1:u67RKh3BRmS4FYLH+rN3N4T5fqpd9m2ttAwBJYEdosU=
84-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
85-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
86-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
87-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
84+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
85+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
86+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
87+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
8888
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
8989
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
9090
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
@@ -101,6 +101,8 @@ github.com/aws/aws-sdk-go-v2/service/kms v1.50.1 h1:wb/PYYm3wlcqGzw7Ls4GD3X5+seD
101101
github.com/aws/aws-sdk-go-v2/service/kms v1.50.1/go.mod h1:xvHowJ6J9CuaFE04S8fitWQXytf4sHz3DTPGhw9FtmU=
102102
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
103103
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
104+
github.com/aws/aws-sdk-go-v2/service/scheduler v1.17.24 h1:F4gvh9TJcEZVqirKpX/FBWEMK6tvnGSVf4FWDvFtSQw=
105+
github.com/aws/aws-sdk-go-v2/service/scheduler v1.17.24/go.mod h1:0/mvOL++cUfpS4KgHigHDo+x8KLYG/grOTyIOw3KCPM=
104106
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.2 h1:hezAo5AQM0moD4qitsn8bZuc2WE/MmP+cySGfJWEi1A=
105107
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.2/go.mod h1:7+wvNfdX7NZtxNyVLbbS89gYldQ3H+1nlVRr7J9KQDA=
106108
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
@@ -113,8 +115,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA
113115
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
114116
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
115117
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
116-
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
117-
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
118+
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
119+
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
118120
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
119121
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
120122
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=

lambda/handle.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ func handleSecret(props BaseProps) (physicalResourceID string, data map[string]i
7171

7272
logger.Info("Secret data updated", "ARN", *putSecretValueResp.ARN)
7373

74+
// Handle expiration schedule upsert for flat JSON secrets when expiration is configured.
75+
if props.properties.Expiration != nil && props.properties.ResourceType == event.SECRET {
76+
stringMapBytes, smErr := props.secretDecryptedData.ToStringMap()
77+
if smErr != nil {
78+
logger.Warn("Could not extract string map for expiration scan", "Error", smErr)
79+
} else {
80+
// Convert map[string][]byte to map[string]string
81+
stringMap := make(map[string]string, len(stringMapBytes))
82+
for k, v := range stringMapBytes {
83+
stringMap[k] = string(v)
84+
}
85+
if expErr := handleExpirationUpsert(props.properties, props.clients, stringMap, *putSecretValueResp.ARN); expErr != nil {
86+
logger.Error("Failed to upsert expiration schedules", "Error", expErr)
87+
return *putSecretValueResp.ARN, nil, expErr
88+
}
89+
}
90+
}
91+
7492
return *putSecretValueResp.ARN, map[string]interface{}{
7593
"ARN": *putSecretValueResp.ARN,
7694
"Name": *putSecretValueResp.Name,

lambda/handle_expiration.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log/slog"
7+
"regexp"
8+
"strings"
9+
"time"
10+
11+
"github.com/markussiebert/cdk-sops-secrets/internal/client"
12+
"github.com/markussiebert/cdk-sops-secrets/internal/event"
13+
)
14+
15+
const defaultExpirationSuffix = "_expiration"
16+
const defaultDaysBeforeExpiration = 14
17+
18+
// expirationMessage is the payload published to the SNS topic.
19+
type expirationMessage struct {
20+
SecretArn string `json:"secretArn"`
21+
KeyName string `json:"keyName"`
22+
ExpirationDate string `json:"expirationDate"`
23+
NotificationDate string `json:"notificationDate"`
24+
DaysBeforeExpiration int `json:"daysBeforeExpiration"`
25+
}
26+
27+
// sanitizeScheduleName converts a key name into a valid EventBridge Scheduler
28+
// schedule name: only [a-zA-Z0-9_-], max 64 characters.
29+
func sanitizeScheduleName(name string) string {
30+
re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
31+
sanitized := re.ReplaceAllString(name, "-")
32+
if len(sanitized) > 64 {
33+
sanitized = sanitized[:64]
34+
}
35+
return sanitized
36+
}
37+
38+
// parseExpirationDate attempts to parse a date string in ISO 8601 / RFC 3339
39+
// formats. Accepted formats: "2006-01-02", "2006-01-02T15:04:05Z07:00".
40+
func parseExpirationDate(value string) (time.Time, error) {
41+
value = strings.TrimSpace(value)
42+
formats := []string{
43+
"2006-01-02",
44+
time.RFC3339,
45+
}
46+
for _, format := range formats {
47+
if t, err := time.Parse(format, value); err == nil {
48+
return t, nil
49+
}
50+
}
51+
return time.Time{}, fmt.Errorf("unsupported date format: %q", value)
52+
}
53+
54+
// scanExpirationKeys scans the flattened secret map for keys that end with the
55+
// given suffix and returns a map of base key → expiration time.
56+
func scanExpirationKeys(secretMap map[string]string, suffix string) map[string]time.Time {
57+
result := make(map[string]time.Time)
58+
for key, value := range secretMap {
59+
if !strings.HasSuffix(key, suffix) {
60+
continue
61+
}
62+
baseKey := strings.TrimSuffix(key, suffix)
63+
t, err := parseExpirationDate(value)
64+
if err != nil {
65+
slog.Warn("Skipping unparseable expiration date",
66+
"key", key, "value", value, "error", err)
67+
continue
68+
}
69+
result[baseKey] = t
70+
}
71+
return result
72+
}
73+
74+
// handleExpirationUpsert creates or updates EventBridge Scheduler schedules for
75+
// all expiration keys found in the decrypted secret.
76+
func handleExpirationUpsert(
77+
props *event.SopsSyncResourcePropertys,
78+
clients client.AwsClient,
79+
secretMap map[string]string,
80+
secretArn string,
81+
) error {
82+
logger := slog.With("Package", "main", "Function", "handleExpirationUpsert")
83+
exp := props.Expiration
84+
85+
suffix := defaultExpirationSuffix
86+
if exp.ExpirationSuffix != nil && *exp.ExpirationSuffix != "" {
87+
suffix = *exp.ExpirationSuffix
88+
}
89+
90+
days := defaultDaysBeforeExpiration
91+
if exp.DaysBeforeExpiration != nil {
92+
days = *exp.DaysBeforeExpiration
93+
}
94+
95+
expirations := scanExpirationKeys(secretMap, suffix)
96+
if len(expirations) == 0 {
97+
logger.Info("No expiration keys found in secret", "Suffix", suffix)
98+
return nil
99+
}
100+
101+
for baseKey, expiresAt := range expirations {
102+
notifyAt := expiresAt.UTC().AddDate(0, 0, -days)
103+
// Skip schedules whose notification time is already in the past.
104+
if notifyAt.Before(time.Now().UTC()) {
105+
logger.Warn("Notification time is in the past, skipping schedule",
106+
"baseKey", baseKey,
107+
"expiresAt", expiresAt.Format("2006-01-02"),
108+
"notifyAt", notifyAt.Format("2006-01-02"),
109+
)
110+
continue
111+
}
112+
113+
scheduleName := sanitizeScheduleName(baseKey)
114+
// EventBridge Scheduler at() expression format: at(YYYY-MM-DDTHH:mm:ss)
115+
scheduleExpr := fmt.Sprintf("at(%s)", notifyAt.Format("2006-01-02T15:04:05"))
116+
117+
msg := expirationMessage{
118+
SecretArn: secretArn,
119+
KeyName: baseKey,
120+
ExpirationDate: expiresAt.UTC().Format("2006-01-02"),
121+
NotificationDate: notifyAt.UTC().Format("2006-01-02"),
122+
DaysBeforeExpiration: days,
123+
}
124+
msgJSON, err := json.Marshal(msg)
125+
if err != nil {
126+
return fmt.Errorf("failed to marshal expiration message for key %s: %v", baseKey, err)
127+
}
128+
129+
logger.Info("Upserting expiration schedule",
130+
"scheduleName", scheduleName,
131+
"group", exp.ScheduleGroupName,
132+
"expression", scheduleExpr,
133+
)
134+
135+
if err := clients.SchedulerCreateOrUpdateSchedule(
136+
scheduleName,
137+
exp.ScheduleGroupName,
138+
scheduleExpr,
139+
exp.TopicArn,
140+
exp.SchedulerRoleArn,
141+
string(msgJSON),
142+
); err != nil {
143+
return fmt.Errorf("failed to upsert schedule for key %s: %v", baseKey, err)
144+
}
145+
}
146+
return nil
147+
}
148+
149+
// handleExpirationDelete removes all expiration schedules for a secret by
150+
// listing and deleting every schedule in the group.
151+
func handleExpirationDelete(
152+
props *event.SopsSyncResourcePropertys,
153+
clients client.AwsClient,
154+
) error {
155+
logger := slog.With("Package", "main", "Function", "handleExpirationDelete")
156+
exp := props.Expiration
157+
158+
names, err := clients.SchedulerListSchedules(exp.ScheduleGroupName)
159+
if err != nil {
160+
// If the group doesn't exist yet (e.g. stack was never fully deployed)
161+
// treat it as a no-op rather than a hard failure.
162+
logger.Warn("Could not list schedules, assuming group does not exist",
163+
"Group", exp.ScheduleGroupName, "Error", err)
164+
return nil
165+
}
166+
167+
for _, name := range names {
168+
logger.Info("Deleting expiration schedule", "Name", name, "Group", exp.ScheduleGroupName)
169+
if err := clients.SchedulerDeleteSchedule(name, exp.ScheduleGroupName); err != nil {
170+
// Log and continue so we attempt to delete all schedules even if one fails.
171+
logger.Error("Failed to delete schedule", "Name", name, "Error", err)
172+
}
173+
}
174+
return nil
175+
}

0 commit comments

Comments
 (0)