Skip to content

Commit 72e9f35

Browse files
committed
feat(coderd/database/dbpurge): make API keys retention configurable
Replace hardcoded 7-day retention for expired API keys with configurable retention from deployment settings. Falls back to global retention when not set, and skips deletion entirely when effective retention is 0. Depends on #21021 Updates #20743
1 parent a21395a commit 72e9f35

File tree

2 files changed

+217
-12
lines changed

2 files changed

+217
-12
lines changed

coderd/database/dbpurge/dbpurge.go

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,18 +83,27 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder
8383
if err := tx.ExpirePrebuildsAPIKeys(ctx, dbtime.Time(start)); err != nil {
8484
return xerrors.Errorf("failed to expire prebuilds user api keys: %w", err)
8585
}
86-
expiredAPIKeys, err := tx.DeleteExpiredAPIKeys(ctx, database.DeleteExpiredAPIKeysParams{
87-
// Leave expired keys for a week to allow the backend to know the difference
88-
// between a 404 and an expired key. This purge code is just to bound the size of
89-
// the table to something more reasonable.
90-
Before: dbtime.Time(start.Add(time.Hour * 24 * 7 * -1)),
91-
// There could be a lot of expired keys here, so set a limit to prevent this
92-
// taking too long.
93-
// This runs every 10 minutes, so it deletes ~1.5m keys per day at most.
94-
LimitCount: 10000,
95-
})
96-
if err != nil {
97-
return xerrors.Errorf("failed to delete expired api keys: %w", err)
86+
87+
var expiredAPIKeys int64
88+
apiKeysRetention := vals.Retention.APIKeys.Value()
89+
if apiKeysRetention == 0 {
90+
apiKeysRetention = vals.Retention.Global.Value()
91+
}
92+
if apiKeysRetention > 0 {
93+
// Delete keys that have been expired for at least the retention period.
94+
// This allows the backend to return a more helpful error when a user
95+
// tries to use an expired key.
96+
deleteExpiredKeysBefore := start.Add(-apiKeysRetention)
97+
expiredAPIKeys, err = tx.DeleteExpiredAPIKeys(ctx, database.DeleteExpiredAPIKeysParams{
98+
Before: dbtime.Time(deleteExpiredKeysBefore),
99+
// There could be a lot of expired keys here, so set a limit to prevent
100+
// this taking too long. This runs every 10 minutes, so it deletes
101+
// ~1.5m keys per day at most.
102+
LimitCount: 10000,
103+
})
104+
if err != nil {
105+
return xerrors.Errorf("failed to delete expired api keys: %w", err)
106+
}
98107
}
99108
deleteOldTelemetryLocksBefore := start.Add(-maxTelemetryHeartbeatAge)
100109
if err := tx.DeleteOldTelemetryLocks(ctx, deleteOldTelemetryLocksBefore); err != nil {

coderd/database/dbpurge/dbpurge_test.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,3 +1245,199 @@ func TestDeleteOldAuditLogs(t *testing.T) {
12451245
require.NotContains(t, logIDs, oldCreateLog.ID, "old create log should be deleted by audit logs retention")
12461246
})
12471247
}
1248+
1249+
func TestDeleteExpiredAPIKeys(t *testing.T) {
1250+
t.Parallel()
1251+
1252+
t.Run("RetentionEnabled", func(t *testing.T) {
1253+
t.Parallel()
1254+
1255+
ctx := testutil.Context(t, testutil.WaitShort)
1256+
1257+
clk := quartz.NewMock(t)
1258+
now := time.Date(2025, 1, 15, 7, 30, 0, 0, time.UTC)
1259+
retentionPeriod := 7 * 24 * time.Hour // 7 days
1260+
expiredLongAgo := now.Add(-retentionPeriod).Add(-24 * time.Hour) // Expired 8 days ago (should be deleted)
1261+
expiredRecently := now.Add(-retentionPeriod).Add(24 * time.Hour) // Expired 6 days ago (should be kept)
1262+
notExpired := now.Add(24 * time.Hour) // Expires tomorrow (should be kept)
1263+
clk.Set(now).MustWait(ctx)
1264+
1265+
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
1266+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
1267+
user := dbgen.User(t, db, database.User{})
1268+
1269+
// Create API key that expired long ago (should be deleted)
1270+
oldExpiredKey, _ := dbgen.APIKey(t, db, database.APIKey{
1271+
UserID: user.ID,
1272+
ExpiresAt: expiredLongAgo,
1273+
TokenName: "old-expired-key",
1274+
})
1275+
1276+
// Create API key that expired recently (should be kept)
1277+
recentExpiredKey, _ := dbgen.APIKey(t, db, database.APIKey{
1278+
UserID: user.ID,
1279+
ExpiresAt: expiredRecently,
1280+
TokenName: "recent-expired-key",
1281+
})
1282+
1283+
// Create API key that hasn't expired yet (should be kept)
1284+
activeKey, _ := dbgen.APIKey(t, db, database.APIKey{
1285+
UserID: user.ID,
1286+
ExpiresAt: notExpired,
1287+
TokenName: "active-key",
1288+
})
1289+
1290+
// Run the purge with configured retention period
1291+
done := awaitDoTick(ctx, t, clk)
1292+
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{
1293+
Retention: codersdk.RetentionConfig{
1294+
APIKeys: serpent.Duration(retentionPeriod),
1295+
},
1296+
}, clk)
1297+
defer closer.Close()
1298+
testutil.TryReceive(ctx, t, done)
1299+
1300+
// Verify results
1301+
_, err := db.GetAPIKeyByID(ctx, oldExpiredKey.ID)
1302+
require.Error(t, err, "old expired key should be deleted")
1303+
1304+
_, err = db.GetAPIKeyByID(ctx, recentExpiredKey.ID)
1305+
require.NoError(t, err, "recently expired key should be kept")
1306+
1307+
_, err = db.GetAPIKeyByID(ctx, activeKey.ID)
1308+
require.NoError(t, err, "active key should be kept")
1309+
})
1310+
1311+
t.Run("RetentionDisabled", func(t *testing.T) {
1312+
t.Parallel()
1313+
1314+
ctx := testutil.Context(t, testutil.WaitShort)
1315+
1316+
clk := quartz.NewMock(t)
1317+
now := time.Date(2025, 1, 15, 7, 30, 0, 0, time.UTC)
1318+
expiredLongAgo := now.Add(-365 * 24 * time.Hour) // Expired 1 year ago
1319+
clk.Set(now).MustWait(ctx)
1320+
1321+
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
1322+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
1323+
user := dbgen.User(t, db, database.User{})
1324+
1325+
// Create API key that expired long ago (should NOT be deleted when retention is 0)
1326+
oldExpiredKey, _ := dbgen.APIKey(t, db, database.APIKey{
1327+
UserID: user.ID,
1328+
ExpiresAt: expiredLongAgo,
1329+
TokenName: "old-expired-key",
1330+
})
1331+
1332+
// Run the purge with retention disabled (0)
1333+
done := awaitDoTick(ctx, t, clk)
1334+
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{
1335+
Retention: codersdk.RetentionConfig{
1336+
APIKeys: serpent.Duration(0), // disabled
1337+
},
1338+
}, clk)
1339+
defer closer.Close()
1340+
testutil.TryReceive(ctx, t, done)
1341+
1342+
// Verify old expired key is still present
1343+
_, err := db.GetAPIKeyByID(ctx, oldExpiredKey.ID)
1344+
require.NoError(t, err, "old expired key should NOT be deleted when retention is disabled")
1345+
})
1346+
1347+
t.Run("GlobalRetentionFallback", func(t *testing.T) {
1348+
t.Parallel()
1349+
1350+
ctx := testutil.Context(t, testutil.WaitShort)
1351+
1352+
clk := quartz.NewMock(t)
1353+
now := time.Date(2025, 1, 15, 7, 30, 0, 0, time.UTC)
1354+
retentionPeriod := 14 * 24 * time.Hour // 14 days global
1355+
expiredLongAgo := now.Add(-retentionPeriod).Add(-24 * time.Hour) // Expired 15 days ago (should be deleted)
1356+
expiredRecently := now.Add(-retentionPeriod).Add(24 * time.Hour) // Expired 13 days ago (should be kept)
1357+
clk.Set(now).MustWait(ctx)
1358+
1359+
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
1360+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
1361+
user := dbgen.User(t, db, database.User{})
1362+
1363+
// Create API key that expired long ago (should be deleted)
1364+
oldExpiredKey, _ := dbgen.APIKey(t, db, database.APIKey{
1365+
UserID: user.ID,
1366+
ExpiresAt: expiredLongAgo,
1367+
TokenName: "old-expired-key",
1368+
})
1369+
1370+
// Create API key that expired recently (should be kept)
1371+
recentExpiredKey, _ := dbgen.APIKey(t, db, database.APIKey{
1372+
UserID: user.ID,
1373+
ExpiresAt: expiredRecently,
1374+
TokenName: "recent-expired-key",
1375+
})
1376+
1377+
// Run the purge with global retention (API keys retention is 0, so it falls back)
1378+
done := awaitDoTick(ctx, t, clk)
1379+
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{
1380+
Retention: codersdk.RetentionConfig{
1381+
Global: serpent.Duration(retentionPeriod), // Use global
1382+
APIKeys: serpent.Duration(0), // Not set, should fall back to global
1383+
},
1384+
}, clk)
1385+
defer closer.Close()
1386+
testutil.TryReceive(ctx, t, done)
1387+
1388+
// Verify results
1389+
_, err := db.GetAPIKeyByID(ctx, oldExpiredKey.ID)
1390+
require.Error(t, err, "old expired key should be deleted via global retention")
1391+
1392+
_, err = db.GetAPIKeyByID(ctx, recentExpiredKey.ID)
1393+
require.NoError(t, err, "recently expired key should be kept")
1394+
})
1395+
1396+
t.Run("CustomRetention30Days", func(t *testing.T) {
1397+
t.Parallel()
1398+
1399+
ctx := testutil.Context(t, testutil.WaitShort)
1400+
1401+
clk := quartz.NewMock(t)
1402+
now := time.Date(2025, 1, 15, 7, 30, 0, 0, time.UTC)
1403+
retentionPeriod := 30 * 24 * time.Hour // 30 days
1404+
expiredLongAgo := now.Add(-retentionPeriod).Add(-24 * time.Hour) // Expired 31 days ago (should be deleted)
1405+
expiredRecently := now.Add(-retentionPeriod).Add(24 * time.Hour) // Expired 29 days ago (should be kept)
1406+
clk.Set(now).MustWait(ctx)
1407+
1408+
db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
1409+
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
1410+
user := dbgen.User(t, db, database.User{})
1411+
1412+
// Create API key that expired long ago (should be deleted)
1413+
oldExpiredKey, _ := dbgen.APIKey(t, db, database.APIKey{
1414+
UserID: user.ID,
1415+
ExpiresAt: expiredLongAgo,
1416+
TokenName: "old-expired-key",
1417+
})
1418+
1419+
// Create API key that expired recently (should be kept)
1420+
recentExpiredKey, _ := dbgen.APIKey(t, db, database.APIKey{
1421+
UserID: user.ID,
1422+
ExpiresAt: expiredRecently,
1423+
TokenName: "recent-expired-key",
1424+
})
1425+
1426+
// Run the purge with 30-day retention
1427+
done := awaitDoTick(ctx, t, clk)
1428+
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{
1429+
Retention: codersdk.RetentionConfig{
1430+
APIKeys: serpent.Duration(retentionPeriod),
1431+
},
1432+
}, clk)
1433+
defer closer.Close()
1434+
testutil.TryReceive(ctx, t, done)
1435+
1436+
// Verify results
1437+
_, err := db.GetAPIKeyByID(ctx, oldExpiredKey.ID)
1438+
require.Error(t, err, "old expired key should be deleted with 30-day retention")
1439+
1440+
_, err = db.GetAPIKeyByID(ctx, recentExpiredKey.ID)
1441+
require.NoError(t, err, "recently expired key should be kept with 30-day retention")
1442+
})
1443+
}

0 commit comments

Comments
 (0)