• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

square / keywhiz / 3614567422

pending completion
3614567422

push

github

Michael Montgomery
Adding support for hard-deleting secrets

72 of 72 new or added lines in 5 files covered. (100.0%)

5022 of 6509 relevant lines covered (77.15%)

0.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.91
/server/src/main/java/keywhiz/service/daos/SecretSeriesDAO.java
1
/*
2
 * Copyright (C) 2015 Square, Inc.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *      http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16

17
package keywhiz.service.daos;
18

19
import com.fasterxml.jackson.core.JsonProcessingException;
20
import com.fasterxml.jackson.databind.ObjectMapper;
21
import com.google.common.annotations.VisibleForTesting;
22
import com.google.common.base.Throwables;
23
import com.google.common.collect.ImmutableList;
24
import com.google.common.collect.ImmutableMap;
25
import keywhiz.api.model.Group;
26
import keywhiz.api.model.SecretSeries;
27
import keywhiz.jooq.tables.records.SecretsContentRecord;
28
import keywhiz.jooq.tables.records.SecretsRecord;
29
import keywhiz.service.config.Readonly;
30
import keywhiz.service.crypto.RowHmacGenerator;
31
import org.joda.time.DateTime;
32
import org.jooq.Configuration;
33
import org.jooq.DSLContext;
34
import org.jooq.Field;
35
import org.jooq.Record;
36
import org.jooq.Record1;
37
import org.jooq.SelectQuery;
38
import org.jooq.Table;
39
import org.jooq.impl.DSL;
40

41
import javax.annotation.Nullable;
42
import javax.inject.Inject;
43
import javax.ws.rs.BadRequestException;
44
import java.time.Instant;
45
import java.time.OffsetDateTime;
46
import java.util.List;
47
import java.util.Map;
48
import java.util.Optional;
49
import java.util.UUID;
50

51
import static com.google.common.base.Preconditions.checkNotNull;
52
import static keywhiz.jooq.tables.Accessgrants.ACCESSGRANTS;
53
import static keywhiz.jooq.tables.Groups.GROUPS;
54
import static keywhiz.jooq.tables.Secrets.SECRETS;
55
import static keywhiz.jooq.tables.SecretsContent.SECRETS_CONTENT;
56
import static org.jooq.impl.DSL.decode;
57
import static org.jooq.impl.DSL.least;
58
import static org.jooq.impl.DSL.val;
59

60

61
/**
62
 * Interacts with 'secrets' table and actions on {@link SecretSeries} entities.
63
 */
64
public class SecretSeriesDAO {
65
  private final DSLContext dslContext;
66
  private final ObjectMapper mapper;
67
  private final SecretSeriesMapper secretSeriesMapper;
68
  private final RowHmacGenerator rowHmacGenerator;
69

70
  private SecretSeriesDAO(
71
      DSLContext dslContext,
72
      ObjectMapper mapper,
73
      SecretSeriesMapper secretSeriesMapper,
74
      RowHmacGenerator rowHmacGenerator) {
1✔
75
    this.dslContext = dslContext;
1✔
76
    this.mapper = mapper;
1✔
77
    this.secretSeriesMapper = secretSeriesMapper;
1✔
78
    this.rowHmacGenerator = rowHmacGenerator;
1✔
79
  }
1✔
80

81
  long createSecretSeries(
82
      String name,
83
      Long ownerId,
84
      String creator,
85
      String description,
86
      @Nullable String type,
87
      @Nullable Map<String, String> generationOptions,
88
      long now) {
89
    long generatedId = rowHmacGenerator.getNextLongSecure();
1✔
90
    return createSecretSeries(
1✔
91
        generatedId,
92
        name,
93
        ownerId,
94
        creator,
95
        description,
96
        type,
97
        generationOptions,
98
        now);
99
  }
100

101
  @VisibleForTesting
102
  long createSecretSeries(
103
      long id,
104
      String name,
105
      Long ownerId,
106
      String creator,
107
      String description,
108
      @Nullable String type,
109
      @Nullable Map<String, String> generationOptions,
110
      long now) {
111
    SecretsRecord r = dslContext.newRecord(SECRETS);
1✔
112

113
    String rowHmac = computeRowHmac(id, name);
1✔
114

115
    r.setId(id);
1✔
116
    r.setName(name);
1✔
117
    r.setOwner(ownerId);
1✔
118
    r.setDescription(description);
1✔
119
    r.setCreatedby(creator);
1✔
120
    r.setCreatedat(now);
1✔
121
    r.setUpdatedby(creator);
1✔
122
    r.setUpdatedat(now);
1✔
123
    r.setType(type);
1✔
124
    r.setRowHmac(rowHmac);
1✔
125
    if (generationOptions != null) {
1✔
126
      try {
127
        r.setOptions(mapper.writeValueAsString(generationOptions));
1✔
128
      } catch (JsonProcessingException e) {
×
129
        // Serialization of a Map<String, String> can never fail.
130
        throw Throwables.propagate(e);
×
131
      }
1✔
132
    } else {
133
      r.setOptions("{}");
1✔
134
    }
135
    r.store();
1✔
136

137
    return r.getId();
1✔
138
  }
139

140
  void updateSecretSeries(
141
      long secretId,
142
      String name,
143
      Long ownerId,
144
      String creator,
145
      String description,
146
      @Nullable String type,
147
      @Nullable Map<String, String> generationOptions,
148
      long now) {
149

150
    if (generationOptions == null) {
1✔
151
      generationOptions = ImmutableMap.of();
1✔
152
    }
153

154
    try {
155
      String rowHmac = computeRowHmac(secretId, name);
1✔
156

157
      dslContext.update(SECRETS)
1✔
158
          .set(SECRETS.NAME, name)
1✔
159
          .set(SECRETS.OWNER, ownerId)
1✔
160
          .set(SECRETS.DESCRIPTION, description)
1✔
161
          .set(SECRETS.UPDATEDBY, creator)
1✔
162
          .set(SECRETS.UPDATEDAT, now)
1✔
163
          .set(SECRETS.TYPE, type)
1✔
164
          .set(SECRETS.OPTIONS, mapper.writeValueAsString(generationOptions))
1✔
165
          .set(SECRETS.ROW_HMAC, rowHmac)
1✔
166
          .where(SECRETS.ID.eq(secretId))
1✔
167
          .execute();
1✔
168
    } catch (JsonProcessingException e) {
×
169
      // Serialization of a Map<String, String> can never fail.
170
      throw Throwables.propagate(e);
×
171
    }
1✔
172
  }
1✔
173

174
  public int setExpiration(long secretContentId, Instant expiration) {
175
    Field<Long> minExpiration = decode()
1✔
176
        .when(SECRETS_CONTENT.EXPIRY.eq(0L), val(expiration.getEpochSecond()))
1✔
177
        .otherwise(least(SECRETS_CONTENT.EXPIRY, val(expiration.getEpochSecond())));
1✔
178

179
    return dslContext.update(SECRETS_CONTENT)
1✔
180
        .set(SECRETS_CONTENT.EXPIRY, minExpiration)
1✔
181
        .where(SECRETS_CONTENT.ID.eq(secretContentId))
1✔
182
        .execute();
1✔
183
  }
184

185
  public int setRowHmacByName(String secretName, String hmac) {
186
    return dslContext.update(SECRETS)
1✔
187
        .set(SECRETS.ROW_HMAC, hmac)
1✔
188
        .where(SECRETS.NAME.eq(secretName))
1✔
189
        .execute();
1✔
190
  }
191

192
  public int setHmac(long secretContentId, String hmac) {
193
    return dslContext.update(SECRETS_CONTENT)
×
194
        .set(SECRETS_CONTENT.CONTENT_HMAC, hmac)
×
195
        .where(SECRETS_CONTENT.ID.eq(secretContentId))
×
196
        .execute();
×
197
  }
198

199
  public int setCurrentVersion(long secretId, long secretContentId, String updater, long now) {
200
    long checkId;
201
    Record1<Long> r = dslContext.select(SECRETS_CONTENT.SECRETID)
1✔
202
        .from(SECRETS_CONTENT)
1✔
203
        .where(SECRETS_CONTENT.ID.eq(secretContentId))
1✔
204
        .fetchOne();
1✔
205
    if (r == null) {
1✔
206
      throw new BadRequestException(
1✔
207
          String.format("The requested version %d is not a known version of this secret",
1✔
208
              secretContentId));
1✔
209
    }
210

211
    checkId = r.value1();
1✔
212
    if (checkId != secretId) {
1✔
213
      throw new IllegalStateException(String.format(
1✔
214
          "tried to reset secret with id %d to version %d, but this version is not associated with this secret",
215
          secretId, secretContentId));
1✔
216
    }
217

218
    return dslContext.update(SECRETS)
1✔
219
        .set(SECRETS.CURRENT, secretContentId)
1✔
220
        .set(SECRETS.UPDATEDBY, updater)
1✔
221
        .set(SECRETS.UPDATEDAT, now)
1✔
222
        .where(SECRETS.ID.eq(secretId))
1✔
223
        .execute();
1✔
224
  }
225

226
  public Optional<SecretSeries> getSecretSeriesById(long id) {
227
    SecretsRecord r =
1✔
228
        dslContext.fetchOne(SECRETS, SECRETS.ID.eq(id).and(SECRETS.CURRENT.isNotNull()));
1✔
229
    return Optional.ofNullable(r).map(secretSeriesMapper::map);
1✔
230
  }
231

232
  public Optional<SecretSeries> getDeletedSecretSeriesById(long id) {
233
    SecretsRecord r =
1✔
234
        dslContext.fetchOne(SECRETS, SECRETS.ID.eq(id).and(SECRETS.CURRENT.isNull()));
1✔
235
    return Optional.ofNullable(r).map(secretSeriesMapper::map);
1✔
236
  }
237

238
  public Optional<SecretSeries> getSecretSeriesByName(String name) {
239
    SecretsRecord r =
1✔
240
        dslContext.fetchOne(SECRETS, SECRETS.NAME.eq(name).and(SECRETS.CURRENT.isNotNull()));
1✔
241
    return Optional.ofNullable(r).map(secretSeriesMapper::map);
1✔
242
  }
243

244
  public List<SecretSeries> getSecretSeriesByDeletedName(String name) {
245
    String lookup = "." + name + ".%";
1✔
246
    return dslContext.fetch(SECRETS, SECRETS.NAME.like(lookup).and(SECRETS.CURRENT.isNull())).map(secretSeriesMapper::map);
1✔
247
  }
248

249
  public List<SecretSeries> getMultipleSecretSeriesByName(List<String> names) {
250
    return dslContext.fetch(SECRETS, SECRETS.NAME.in(names).and(SECRETS.CURRENT.isNotNull())).map(secretSeriesMapper::map);
1✔
251
  }
252

253
  public ImmutableList<SecretSeries> getSecretSeries(@Nullable Long expireMaxTime,
254
      @Nullable Group group, @Nullable Long expireMinTime, @Nullable String minName,
255
      @Nullable Integer limit) {
256
    Table<SecretsContentRecord> secretsContentTable = SECRETS_CONTENT;
1✔
257
    if (expireMaxTime != null && expireMaxTime > 0) {
1✔
258
      // Force this join to use the index on the secrets_content.expiry
259
      // field. The optimizer may fail to use this index when the SELECT
260
      // examines a large number of rows, causing significant performance
261
      // degradation.
262
      secretsContentTable = secretsContentTable.useIndexForJoin("secrets_content_expiry");
1✔
263
    }
264

265
    SelectQuery<Record> select = dslContext
1✔
266
          .select()
1✔
267
          .from(SECRETS)
1✔
268
          .join(secretsContentTable)
1✔
269
          .on(SECRETS.CURRENT.equal(SECRETS_CONTENT.ID))
1✔
270
          .where(SECRETS.CURRENT.isNotNull())
1✔
271
          .getQuery();
1✔
272
    select.addOrderBy(SECRETS_CONTENT.EXPIRY.asc(), SECRETS.NAME.asc());
1✔
273

274
    // Set an upper bound on expiration dates
275
    if (expireMaxTime != null && expireMaxTime > 0) {
1✔
276
      // Set a lower bound of "now" on the expiration only if it isn't configured separately
277
      if (expireMinTime == null || expireMinTime == 0) {
1✔
278
        long now = System.currentTimeMillis() / 1000L;
1✔
279
        select.addConditions(SECRETS_CONTENT.EXPIRY.greaterOrEqual(now));
1✔
280
      }
281
      select.addConditions(SECRETS_CONTENT.EXPIRY.lessThan(expireMaxTime));
1✔
282
    }
283

284
    if (expireMinTime != null && expireMinTime > 0) {
1✔
285
      // set a lower bound on expiration dates, using the secret name as a tiebreaker
286
      select.addConditions(SECRETS_CONTENT.EXPIRY.greaterThan(expireMinTime)
1✔
287
          .or(SECRETS_CONTENT.EXPIRY.eq(expireMinTime)
1✔
288
              .and(SECRETS.NAME.greaterOrEqual(minName))));
1✔
289
    }
290

291
    if (group != null) {
1✔
292
      select.addJoin(ACCESSGRANTS, SECRETS.ID.eq(ACCESSGRANTS.SECRETID));
1✔
293
      select.addJoin(GROUPS, GROUPS.ID.eq(ACCESSGRANTS.GROUPID));
1✔
294
      select.addConditions(GROUPS.NAME.eq(group.getName()));
1✔
295
    }
296

297
    if (limit != null && limit >= 0) {
1✔
298
      select.addLimit(limit);
1✔
299
    }
300

301
    List<SecretSeries> r = select.fetchInto(SECRETS).map(secretSeriesMapper);
1✔
302
    return ImmutableList.copyOf(r);
1✔
303
  }
304

305
  public ImmutableList<SecretSeries> getSecretSeriesBatched(int idx, int num, boolean newestFirst) {
306
    SelectQuery<Record> select = dslContext
1✔
307
        .select()
1✔
308
        .from(SECRETS)
1✔
309
        .join(SECRETS_CONTENT)
1✔
310
        .on(SECRETS.CURRENT.equal(SECRETS_CONTENT.ID))
1✔
311
        .where(SECRETS.CURRENT.isNotNull())
1✔
312
        .getQuery();
1✔
313
    if (newestFirst) {
1✔
314
      select.addOrderBy(SECRETS.CREATEDAT.desc());
1✔
315
    } else {
316
      select.addOrderBy(SECRETS.CREATEDAT.asc());
1✔
317
    }
318
    select.addLimit(idx, num);
1✔
319

320
    List<SecretSeries> r = select.fetchInto(SECRETS).map(secretSeriesMapper);
1✔
321
    return ImmutableList.copyOf(r);
1✔
322
  }
323

324
  public void hardDeleteSecretSeriesByName(String name) {
325
    dslContext.transaction(configuration -> {
1✔
326
      DSLContext dslContext = DSL.using(configuration);
1✔
327

328
      SecretsRecord record = dslContext.select()
1✔
329
          .from(SECRETS)
1✔
330
          .where(SECRETS.NAME.eq(name))
1✔
331
          .forUpdate()
1✔
332
          .fetchOneInto(SECRETS);
1✔
333

334
      hardDeleteSecretSeries(dslContext, record);
1✔
335
    });
1✔
336
  }
1✔
337

338
  public void hardDeleteSecretSeriesById(Long id) {
339
    dslContext.transaction(configuration -> {
×
340
      DSLContext dslContext = DSL.using(configuration);
×
341

342
      SecretsRecord record = dslContext.select()
×
343
          .from(SECRETS)
×
344
          .where(SECRETS.ID.eq(id))
×
345
          .forUpdate()
×
346
          .fetchOneInto(SECRETS);
×
347

348
      hardDeleteSecretSeries(dslContext, record);
×
349
    });
×
350
  }
×
351

352
  private static void hardDeleteSecretSeries(DSLContext dslContext, SecretsRecord record) {
353
    if (record == null) {
1✔
354
      return;
×
355
    }
356

357
    dslContext.deleteFrom(SECRETS_CONTENT)
1✔
358
        .where(SECRETS_CONTENT.SECRETID.eq(record.getId()))
1✔
359
        .execute();
1✔
360
    dslContext.deleteFrom(SECRETS)
1✔
361
        .where(SECRETS.ID.eq(record.getId()))
1✔
362
        .execute();
1✔
363
    dslContext.deleteFrom(ACCESSGRANTS)
1✔
364
        .where(ACCESSGRANTS.SECRETID.eq(record.getId()))
1✔
365
        .execute();
1✔
366
  }
1✔
367

368
  public void softDeleteSecretSeriesByName(String name) {
369
    dslContext.transaction(configuration -> {
1✔
370
      // find the record and lock it until this transaction is complete
371
      SecretsRecord record = DSL.using(configuration)
1✔
372
          .select()
1✔
373
          .from(SECRETS)
1✔
374
          .where(SECRETS.NAME.eq(name).and(SECRETS.CURRENT.isNotNull()))
1✔
375
          .forUpdate()
1✔
376
          .fetchOneInto(SECRETS);
1✔
377

378
      softDeleteSecretSeries(DSL.using(configuration), record);
1✔
379
    });
1✔
380
  }
1✔
381

382
  public void softDeleteSecretSeriesById(long id) {
383
    dslContext.transaction(configuration -> {
1✔
384
      // find the record and lock it until this transaction is complete
385
      SecretsRecord record = DSL.using(configuration)
1✔
386
          .select()
1✔
387
          .from(SECRETS)
1✔
388
          .where(SECRETS.ID.eq(id).and(SECRETS.CURRENT.isNotNull()))
1✔
389
          .forUpdate()
1✔
390
          .fetchOneInto(SECRETS);
1✔
391

392
      softDeleteSecretSeries(DSL.using(configuration), record);
1✔
393
    });
1✔
394
  }
1✔
395

396
  private static void softDeleteSecretSeries(DSLContext dslContext, SecretsRecord record) {
397
    if (record == null) {
1✔
398
      return;
×
399
    }
400

401
    long now = OffsetDateTime.now().toEpochSecond();
1✔
402

403
    dslContext
1✔
404
        .update(SECRETS)
1✔
405
        .set(SECRETS.NAME, transformNameForDeletion(record.getName()))
1✔
406
        .set(SECRETS.CURRENT, (Long) null)
1✔
407
        .set(SECRETS.UPDATEDAT, now)
1✔
408
        .where(SECRETS.ID.eq(record.getId()))
1✔
409
        .execute();
1✔
410

411
    dslContext
1✔
412
        .delete(ACCESSGRANTS)
1✔
413
        .where(ACCESSGRANTS.SECRETID.eq(record.getId()))
1✔
414
        .execute();
1✔
415
  }
1✔
416

417
  public void renameSecretSeriesById(long secretId, String name, String creator, long now) {
418
    String rowHmac = computeRowHmac(secretId, name);
1✔
419
    dslContext.update(SECRETS)
1✔
420
        .set(SECRETS.NAME, name)
1✔
421
        .set(SECRETS.ROW_HMAC, rowHmac)
1✔
422
        .set(SECRETS.UPDATEDBY, creator)
1✔
423
        .set(SECRETS.UPDATEDAT, now)
1✔
424
        .where(SECRETS.ID.eq(secretId))
1✔
425
        .execute();
1✔
426
  }
1✔
427

428
  /**
429
   * @return the number of deleted secret series
430
   */
431
  public int countDeletedSecretSeries() {
432
    return dslContext.selectCount()
1✔
433
        .from(SECRETS)
1✔
434
        .where(SECRETS.CURRENT.isNull())
1✔
435
        .fetchOne()
1✔
436
        .value1();
1✔
437
  }
438

439
  /**
440
   * Identify all secret series which were deleted before the given date.
441
   *
442
   * @param deleteBefore the cutoff date; secrets deleted before this date will be returned
443
   * @return IDs for secret series deleted before this date
444
   */
445
  public List<Long> getIdsForSecretSeriesDeletedBeforeDate(DateTime deleteBefore) {
446
    long deleteBeforeSeconds = deleteBefore.getMillis() / 1000;
1✔
447
    return dslContext.select(SECRETS.ID)
1✔
448
        .from(SECRETS)
1✔
449
        .where(SECRETS.CURRENT.isNull())
1✔
450
        .and(SECRETS.UPDATEDAT.le(deleteBeforeSeconds))
1✔
451
        .fetch(SECRETS.ID);
1✔
452
  }
453

454
  /**
455
   * PERMANENTLY REMOVE database records from `secrets` which have the given list of IDs. Does not
456
   * affect the `secrets_content` table.
457
   *
458
   * @param ids the IDs in the `secrets` table to be PERMANENTLY REMOVED
459
   * @return the number of records which were removed
460
   */
461
  public long dangerPermanentlyRemoveRecordsForGivenIDs(List<Long> ids) {
462
    return dslContext.deleteFrom(SECRETS)
1✔
463
        .where(SECRETS.ID.in(ids))
1✔
464
        .execute();
1✔
465
  }
466

467
  public static class SecretSeriesDAOFactory implements DAOFactory<SecretSeriesDAO> {
468
    private final DSLContext jooq;
469
    private final DSLContext readonlyJooq;
470
    private final ObjectMapper objectMapper;
471
    private final SecretSeriesMapper.SecretSeriesMapperFactory secretSeriesMapperFactory;
472
    private final RowHmacGenerator rowHmacGenerator;
473

474
    @Inject public SecretSeriesDAOFactory(
475
        DSLContext jooq,
476
        @Readonly DSLContext readonlyJooq,
477
        ObjectMapper objectMapper,
478
        SecretSeriesMapper.SecretSeriesMapperFactory secretSeriesMapperFactory,
479
        RowHmacGenerator rowHmacGenerator) {
1✔
480
      this.jooq = jooq;
1✔
481
      this.readonlyJooq = readonlyJooq;
1✔
482
      this.objectMapper = objectMapper;
1✔
483
      this.secretSeriesMapperFactory = secretSeriesMapperFactory;
1✔
484
      this.rowHmacGenerator = rowHmacGenerator;
1✔
485
    }
1✔
486

487
    @Override public SecretSeriesDAO readwrite() {
488
      return new SecretSeriesDAO(
1✔
489
          jooq,
490
          objectMapper,
491
          secretSeriesMapperFactory.using(jooq),
1✔
492
          rowHmacGenerator);
493
    }
494

495
    @Override public SecretSeriesDAO readonly() {
496
      return new SecretSeriesDAO(
×
497
          readonlyJooq,
498
          objectMapper,
499
          secretSeriesMapperFactory.using(readonlyJooq),
×
500
          rowHmacGenerator);
501
    }
502

503
    @Override public SecretSeriesDAO using(Configuration configuration) {
504
      DSLContext dslContext = DSL.using(checkNotNull(configuration));
1✔
505
      return new SecretSeriesDAO(
1✔
506
          dslContext,
507
          objectMapper,
508
          secretSeriesMapperFactory.using(dslContext),
1✔
509
          rowHmacGenerator);
510
    }
511
  }
512

513
  // create a new name for the deleted secret, so that deleted secret names can be reused, while
514
  // still having a unique constraint on the name field in the DB
515
  private static String transformNameForDeletion(String name) {
516
    long now = OffsetDateTime.now().toEpochSecond();
1✔
517
    return String.format(".%s.deleted.%d.%s", name, now, UUID.randomUUID());
1✔
518
  }
519

520
  private String computeRowHmac(long secretSeriesId, String secretSeriesName) {
521
    return rowHmacGenerator.computeRowHmac(
1✔
522
        SECRETS.getName(),
1✔
523
        List.of(
1✔
524
            secretSeriesName,
525
            secretSeriesId));
1✔
526
  }
527
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc