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

square / keywhiz / 3733400237

pending completion
3733400237

push

github

GitHub
Regenerating database model to include new secrets expiry column and index (#1173)

5186 of 6711 relevant lines covered (77.28%)

0.77 hits per line

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

91.61
/server/src/main/java/keywhiz/service/resources/automation/v2/SecretResource.java
1
package keywhiz.service.resources.automation.v2;
2

3
import com.codahale.metrics.annotation.ExceptionMetered;
4
import com.codahale.metrics.annotation.Timed;
5
import com.google.common.collect.ImmutableList;
6
import com.google.common.collect.Sets;
7
import io.dropwizard.auth.Auth;
8
import java.time.Instant;
9
import java.util.ArrayList;
10
import java.util.Base64;
11
import java.util.HashMap;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Optional;
15
import java.util.Set;
16
import java.util.stream.Stream;
17
import javax.inject.Inject;
18
import javax.validation.Valid;
19
import javax.ws.rs.BadRequestException;
20
import javax.ws.rs.Consumes;
21
import javax.ws.rs.DELETE;
22
import javax.ws.rs.DefaultValue;
23
import javax.ws.rs.GET;
24
import javax.ws.rs.HEAD;
25
import javax.ws.rs.NotFoundException;
26
import javax.ws.rs.POST;
27
import javax.ws.rs.PUT;
28
import javax.ws.rs.Path;
29
import javax.ws.rs.PathParam;
30
import javax.ws.rs.Produces;
31
import javax.ws.rs.QueryParam;
32
import javax.ws.rs.core.Response;
33
import javax.ws.rs.core.UriBuilder;
34
import keywhiz.KeywhizConfig;
35
import keywhiz.api.automation.v2.CreateOrUpdateSecretRequestV2;
36
import keywhiz.api.automation.v2.CreateSecretRequestV2;
37
import keywhiz.api.automation.v2.ModifyGroupsRequestV2;
38
import keywhiz.api.automation.v2.PartialUpdateSecretRequestV2;
39
import keywhiz.api.automation.v2.SecretContentsRequestV2;
40
import keywhiz.api.automation.v2.SecretContentsResponseV2;
41
import keywhiz.api.automation.v2.SecretDetailResponseV2;
42
import keywhiz.api.automation.v2.SetSecretVersionRequestV2;
43
import keywhiz.api.model.AutomationClient;
44
import keywhiz.api.model.Group;
45
import keywhiz.api.model.SanitizedSecret;
46
import keywhiz.api.model.SanitizedSecretWithGroups;
47
import keywhiz.api.model.SanitizedSecretWithGroupsListAndCursor;
48
import keywhiz.api.model.Secret;
49
import keywhiz.api.model.SecretContent;
50
import keywhiz.api.model.SecretRetrievalCursor;
51
import keywhiz.api.model.SecretSeries;
52
import keywhiz.api.model.SecretSeriesAndContent;
53
import keywhiz.log.AuditLog;
54
import keywhiz.log.Event;
55
import keywhiz.log.EventTag;
56
import keywhiz.log.LogArguments;
57
import keywhiz.service.config.Readonly;
58
import keywhiz.service.crypto.ContentCryptographer;
59
import keywhiz.service.daos.AclDAO;
60
import keywhiz.service.daos.AclDAO.AclDAOFactory;
61
import keywhiz.service.daos.GroupDAO;
62
import keywhiz.service.daos.GroupDAO.GroupDAOFactory;
63
import keywhiz.service.daos.SecretController;
64
import keywhiz.service.daos.SecretController.SecretBuilder;
65
import keywhiz.service.daos.SecretDAO;
66
import keywhiz.service.daos.SecretDAO.SecretDAOFactory;
67
import keywhiz.service.daos.SecretDeletionMode;
68
import keywhiz.service.daos.SecretSeriesDAO;
69
import keywhiz.service.daos.SecretSeriesDAO.SecretSeriesDAOFactory;
70
import keywhiz.service.exceptions.ConflictException;
71
import keywhiz.service.permissions.Action;
72
import keywhiz.service.permissions.PermissionCheck;
73
import keywhiz.service.validation.NullOrValidEnumIgnoreCase;
74
import org.jooq.exception.DataAccessException;
75
import org.slf4j.Logger;
76
import org.slf4j.LoggerFactory;
77

78
import static java.lang.String.format;
79
import static java.nio.charset.StandardCharsets.UTF_8;
80
import static java.time.temporal.ChronoUnit.HOURS;
81
import static java.util.stream.Collectors.toList;
82
import static java.util.stream.Collectors.toSet;
83
import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
84

85
/**
86
 * parentEndpointName automation/v2-secret-management
87
 * resourceDescription Automation endpoints to manage secrets
88
 */
89
@Path("/automation/v2/secrets")
90
public class SecretResource {
91
  private static final Logger logger = LoggerFactory.getLogger(SecretResource.class);
1✔
92

93
  private final SecretController secretController;
94
  private final AclDAO aclDAO;
95
  private final GroupDAO groupDAO;
96
  private final SecretDAO secretDAO;
97
  private final AuditLog auditLog;
98
  private final SecretSeriesDAO secretSeriesDAO;
99
  private final ContentCryptographer cryptographer;
100
  private final SecretController secretControllerReadOnly;
101
  private final PermissionCheck permissionCheck;
102
  private final KeywhizConfig config;
103

104
  @Inject public SecretResource(SecretController secretController, AclDAOFactory aclDAOFactory,
105
      GroupDAOFactory groupDAOFactory, SecretDAOFactory secretDAOFactory, AuditLog auditLog,
106
      SecretSeriesDAOFactory secretSeriesDAOFactory, ContentCryptographer cryptographer,
107
      @Readonly SecretController secretControllerReadOnly, PermissionCheck permissionCheck, KeywhizConfig config) {
1✔
108
    this.secretController = secretController;
1✔
109
    this.aclDAO = aclDAOFactory.readwrite();
1✔
110
    this.groupDAO = groupDAOFactory.readwrite();
1✔
111
    this.secretDAO = secretDAOFactory.readwrite();
1✔
112
    this.auditLog = auditLog;
1✔
113
    this.secretSeriesDAO = secretSeriesDAOFactory.readwrite();
1✔
114
    this.cryptographer = cryptographer;
1✔
115
    this.secretControllerReadOnly = secretControllerReadOnly;
1✔
116
    this.permissionCheck = permissionCheck;
1✔
117
    this.config = config;
1✔
118
  }
1✔
119

120
  @Timed @ExceptionMetered
121
  @HEAD
122
  @Path("{name}")
123
  @Consumes(APPLICATION_JSON)
124
  @LogArguments
125
  public Response secretExists(@Auth AutomationClient automationClient, @PathParam("name") String secretName) {
126
    Response.Status status = secretSeriesDAO.secretSeriesExists(secretName)
1✔
127
        ? Response.Status.OK
1✔
128
        : Response.Status.NOT_FOUND;
1✔
129
    return Response.status(status).build();
1✔
130
  }
131

132
  /**
133
   * Creates a secret and assigns to given groups
134
   *
135
   * @param request JSON request to create a secret
136
   *
137
   * responseMessage 201 Created secret and assigned to given groups
138
   * responseMessage 409 Secret already exists
139
   */
140
  @Timed @ExceptionMetered
141
  @POST
142
  @Consumes(APPLICATION_JSON)
143
  @LogArguments
144
  public Response createSecret(@Auth AutomationClient automationClient,
145
      @Valid CreateSecretRequestV2 request) {
146
    permissionCheck.checkAllowedForTargetTypeOrThrow(automationClient, Action.CREATE, Secret.class);
1✔
147

148
    // allows new version, return version in resulting path
149
    String name = request.name();
1✔
150
    String user = automationClient.getName();
1✔
151

152
    String secretOwner = getSecretOwnerForSecretCreation(request.owner(), automationClient);
1✔
153

154
    SecretBuilder builder = secretController
1✔
155
        .builder(
1✔
156
            name,
157
            request.content(),
1✔
158
            automationClient.getName(),
1✔
159
            request.expiry())
1✔
160
        .withDescription(request.description())
1✔
161
        .withMetadata(request.metadata())
1✔
162
        .withOwnerName(secretOwner)
1✔
163
        .withType(request.type());
1✔
164

165
    Secret secret;
166
    try {
167
      secret = builder.create();
1✔
168
    } catch (DataAccessException e) {
1✔
169
      logger.info(format("Cannot create secret %s", name), e);
1✔
170
      throw new ConflictException(format("Cannot create secret %s.", name));
1✔
171
    }
1✔
172

173
    Map<String, String> extraInfo = new HashMap<>();
1✔
174
    if (request.description() != null) {
1✔
175
      extraInfo.put("description", request.description());
1✔
176
    }
177
    if (request.metadata() != null) {
1✔
178
      extraInfo.put("metadata", request.metadata().toString());
1✔
179
    }
180
    extraInfo.put("expiry", Long.toString(request.expiry()));
1✔
181
    auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_CREATE, user, name, extraInfo));
1✔
182

183
    long secretId = secret.getId();
1✔
184
    groupsToGroupIds(request.groups())
1✔
185
        .forEach((maybeGroupId) -> maybeGroupId.ifPresent(
1✔
186
            (groupId) -> aclDAO.findAndAllowAccess(secretId, groupId, auditLog, user, new HashMap<>())));
1✔
187

188
    UriBuilder uriBuilder = UriBuilder.fromResource(SecretResource.class).path(name);
1✔
189

190
    return Response.created(uriBuilder.build()).build();
1✔
191
  }
192

193
  /**
194
   * Creates or updates (if it exists) a secret.
195
   *
196
   * @param request JSON request to create a secret
197
   *
198
   * responseMessage 201 Created secret and assigned to given groups
199
   */
200
  @Timed @ExceptionMetered
201
  @Path("{name}")
202
  @POST
203
  @Consumes(APPLICATION_JSON)
204
  @LogArguments
205
  public Response createOrUpdateSecret(@Auth AutomationClient automationClient,
206
      @PathParam("name") String name,
207
      @Valid CreateOrUpdateSecretRequestV2 request) {
208
    Optional<SecretSeriesAndContent> maybeSecretSeriesAndContent = secretDAO.getSecretByName(name);
1✔
209
    String secretOwner = request.owner();
1✔
210
    if (maybeSecretSeriesAndContent.isPresent()) {
1✔
211
      permissionCheck.checkAllowedOrThrow(automationClient, Action.UPDATE, maybeSecretSeriesAndContent.get());
1✔
212
    } else {
213
      permissionCheck.checkAllowedForTargetTypeOrThrow(automationClient, Action.CREATE, Secret.class);
1✔
214
      secretOwner = getSecretOwnerForSecretCreation(secretOwner, automationClient);
1✔
215
    }
216

217
    SecretBuilder builder = secretController
1✔
218
        .builder(name, request.content(), automationClient.getName(), request.expiry())
1✔
219
        .withDescription(request.description())
1✔
220
        .withMetadata(request.metadata())
1✔
221
        .withType(request.type())
1✔
222
        .withOwnerName(secretOwner);
1✔
223

224
    builder.createOrUpdate();
1✔
225

226
    Map<String, String> extraInfo = new HashMap<>();
1✔
227
    if (request.description() != null) {
1✔
228
      extraInfo.put("description", request.description());
1✔
229
    }
230
    if (request.metadata() != null) {
1✔
231
      extraInfo.put("metadata", request.metadata().toString());
1✔
232
    }
233
    extraInfo.put("expiry", Long.toString(request.expiry()));
1✔
234
    auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_CREATEORUPDATE, automationClient.getName(), name, extraInfo));
1✔
235

236
    UriBuilder uriBuilder = UriBuilder.fromResource(SecretResource.class).path(name);
1✔
237

238
    return Response.created(uriBuilder.build()).build();
1✔
239
  }
240

241
  @Timed @ExceptionMetered
242
  @Path("{oldName}/rename/{newName}")
243
  @POST
244
  @Consumes(APPLICATION_JSON)
245
  @LogArguments
246
  public Response renameSecret(
247
      @Auth AutomationClient automationClient,
248
      @PathParam("oldName") String oldName,
249
      @PathParam("newName") String newName) {
250
    SecretSeriesAndContent secret = secretDAO.getSecretByName(oldName)
1✔
251
        .orElseThrow(NotFoundException::new);
1✔
252
    permissionCheck.checkAllowedOrThrow(automationClient, Action.UPDATE, secret);
1✔
253

254
    secretDAO.renameSecretById(secret.series().id(), newName, automationClient.getName());
1✔
255

256
    UriBuilder uriBuilder = UriBuilder.fromResource(SecretResource.class).path(newName);
1✔
257
    return Response.created(uriBuilder.build()).build();
1✔
258
  }
259

260
  /**
261
   * Updates a subset of the fields of an existing secret
262
   *
263
   * @param request JSON request to update a secret
264
   *
265
   * responseMessage 201 Created secret and assigned to given groups
266
   */
267
  @Timed @ExceptionMetered
268
  @Path("{name}/partialupdate")
269
  @POST
270
  @Consumes(APPLICATION_JSON)
271
  @LogArguments
272
  public Response partialUpdateSecret(@Auth AutomationClient automationClient,
273
      @PathParam("name") String name,
274
      @Valid PartialUpdateSecretRequestV2 request) {
275
    SecretSeries secretSeries = secretSeriesDAO.getSecretSeriesByName(name).orElseThrow(
1✔
276
        NotFoundException::new);
277
    permissionCheck.checkAllowedOrThrow(automationClient, Action.UPDATE, secretSeries);
1✔
278

279
    secretDAO.partialUpdateSecret(name, automationClient.getName(), request);
1✔
280

281
    Map<String, String> extraInfo = new HashMap<>();
1✔
282
    if (request.description() != null) {
1✔
283
      extraInfo.put("description", request.description());
1✔
284
    }
285
    if (request.metadata() != null) {
1✔
286
      extraInfo.put("metadata", request.metadata().toString());
1✔
287
    }
288
    if (request.expiry() != null) {
1✔
289
      extraInfo.put("expiry", Long.toString(request.expiry()));
1✔
290
    }
291
    auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_UPDATE, automationClient.getName(), name, extraInfo));
1✔
292

293
    UriBuilder uriBuilder = UriBuilder.fromResource(SecretResource.class).path(name);
1✔
294

295
    return Response.created(uriBuilder.build()).build();
1✔
296
  }
297

298
  /**
299
   * Retrieve listing of secret names.  If "idx" and "num" are both provided, retrieve "num"
300
   * names starting at "idx" from a list of secret names ordered by creation date, with
301
   * order depending on "newestFirst" (which defaults to "true")
302
   *
303
   * @param idx the index from which to start retrieval in the list of secret names
304
   * @param num the number of names to retrieve
305
   * @param newestFirst whether to list the most-recently-created names first
306
   * responseMessage 200 List of secret names
307
   * responseMessage 400 Invalid (negative) idx or num
308
   */
309
  @Timed @ExceptionMetered
310
  @GET
311
  @Produces(APPLICATION_JSON)
312
  @LogArguments
313
  public Iterable<String> secretListing(@Auth AutomationClient automationClient,
314
      @QueryParam("idx") Integer idx, @QueryParam("num") Integer num,
315
      @DefaultValue("true") @QueryParam("newestFirst") boolean newestFirst) {
316
    permissionCheck.checkAllowedForTargetTypeOrThrow(automationClient, Action.READ, Secret.class);
1✔
317

318
    if (idx != null && num != null) {
1✔
319
      if (idx < 0 || num < 0) {
1✔
320
        throw new BadRequestException(
1✔
321
            "Index and num must both be positive when retrieving batched secrets!");
322
      }
323
      return secretControllerReadOnly.getSecretsBatched(idx, num, newestFirst).stream()
1✔
324
          .map(SanitizedSecret::name)
1✔
325
          .collect(toList());
1✔
326
    }
327
    return secretControllerReadOnly.getSanitizedSecrets(null, null).stream()
1✔
328
        .map(SanitizedSecret::name)
1✔
329
        .collect(toSet());
1✔
330
  }
331

332
  /**
333
   * Retrieve listing of secrets.  If "idx" and "num" are both provided, retrieve "num"
334
   * names starting at "idx" from a list of secrets ordered by creation date, with
335
   * order depending on "newestFirst" (which defaults to "true")
336
   *
337
   * @param idx the index from which to start retrieval in the list of secrets
338
   * @param num the number of names to retrieve
339
   * @param newestFirst whether to list the most-recently-created names first
340
   * responseMessage 200 List of secret names
341
   * responseMessage 400 Invalid (negative) idx or num
342
   */
343
  @Timed @ExceptionMetered
344
  @Path("/v2")
345
  @GET
346
  @Produces(APPLICATION_JSON)
347
  @LogArguments
348
  public Iterable<SanitizedSecret> secretListingV2(@Auth AutomationClient automationClient,
349
      @QueryParam("idx") Integer idx, @QueryParam("num") Integer num,
350
      @DefaultValue("true") @QueryParam("newestFirst") boolean newestFirst) {
351
    permissionCheck.checkAllowedForTargetTypeOrThrow(automationClient, Action.READ, SanitizedSecret.class);
1✔
352

353
    if (idx != null && num != null) {
1✔
354
      if (idx < 0 || num < 0) {
1✔
355
        throw new BadRequestException(
1✔
356
            "Index and num must both be positive when retrieving batched secrets!");
357
      }
358
      return secretControllerReadOnly.getSecretsBatched(idx, num, newestFirst);
1✔
359
    }
360
    return secretControllerReadOnly.getSanitizedSecrets(null, null);
1✔
361
  }
362

363
  /**
364
   * Retrieve listing of secrets expiring soon
365
   *
366
   * @param time timestamp for farthest expiry to include
367
   *
368
   * responseMessage 200 List of secrets expiring soon
369
   */
370
  @Timed @ExceptionMetered
371
  @Path("expiring/{time}")
372
  @GET
373
  @Produces(APPLICATION_JSON)
374
  @LogArguments
375
  public Iterable<String> secretListingExpiring(@Auth AutomationClient automationClient, @PathParam("time") Long time) {
376
    permissionCheck.checkAllowedForTargetTypeOrThrow(automationClient, Action.READ, Secret.class);
1✔
377

378
    List<SanitizedSecret> secrets = secretControllerReadOnly.getSanitizedSecrets(time, null);
1✔
379
    return secrets.stream()
1✔
380
        .map(SanitizedSecret::name)
1✔
381
        .collect(toList());
1✔
382
  }
383

384
  /**
385
   * Retrieve listing of secrets expiring soon
386
   *
387
   * @param time timestamp for farthest expiry to include
388
   *
389
   * responseMessage 200 List of secrets expiring soon
390
   */
391
  @Timed @ExceptionMetered
392
  @Path("expiring/v2/{time}")
393
  @GET
394
  @Produces(APPLICATION_JSON)
395
  @LogArguments
396
  public Iterable<SanitizedSecret> secretListingExpiringV2(@Auth AutomationClient automationClient, @PathParam("time") Long time) {
397
    permissionCheck.checkAllowedForTargetTypeOrThrow(automationClient, Action.READ, SanitizedSecret.class);
1✔
398

399
    List<SanitizedSecret> secrets = secretControllerReadOnly.getSanitizedSecrets(time, null);
1✔
400
    return secrets;
1✔
401
  }
402

403
  /**
404
   * Retrieve listing of secrets expiring soon (i. e. before the time specified in "maxTime").
405
   * The query parameters can be used to introduce pagination.  Instead of retrieving all secrets
406
   * expiring before maxTime, clients can retrieve all secrets expiring between minTime and maxTime,
407
   * or up to "limit" secrets expiring between minTime and maxTime, or "limit" secrets starting
408
   * at offset "offset" between minTime and maxTime.
409
   *
410
   * Since limit + offset will be slow for large offsets, pagination should primarily be enforced
411
   * by adjusting minTime and maxTime.
412
   *
413
   * The returned secrets will be sorted in increasing order of expiration time.
414
   *
415
   * @param maxTime timestamp for farthest expiry to include (exclusive)
416
   *
417
   * responseMessage 200 List of secrets expiring soon
418
   */
419
  @Timed @ExceptionMetered
420
  @Path("expiring/v3/{time}")
421
  @GET
422
  @Produces(APPLICATION_JSON)
423
  @LogArguments
424
  public Iterable<SanitizedSecretWithGroups> secretListingExpiringV3(@Auth AutomationClient automationClient,
425
      @PathParam("time") Long maxTime) {
426
    permissionCheck.checkAllowedForTargetTypeOrThrow(automationClient, Action.READ, SanitizedSecretWithGroups.class);
1✔
427

428
    return secretControllerReadOnly.getSanitizedSecretsWithGroups(maxTime);
1✔
429
  }
430

431
  /**
432
   * Retrieve listing of secrets expiring soon.  The resulting secrets will be sorted in increasing
433
   * order of expiration time, and alphabetically within the same expiration time.
434
   * <p>
435
   * If names in Keywhiz are no longer unique, this endpoint will potentially skip secrets since it
436
   * returns names strictly greater than the specified name.
437
   * <p>
438
   * If this method returns a cursor, that cursor should be passed back into this method until the
439
   * returned cursor is null.  This allows pagination.
440
   *
441
   * @param minTime timestamp for nearest expiry to include; if null, defaults to current time
442
   * @param maxTime timestamp for farthest expiry to include (exclusive)
443
   * @param limit   maximum number of secrets and groups to return
444
   * @param cursor  input allowing the server to return paginated output (as returned from this
445
   *                method)
446
   *
447
   * responseMessage 200 List of secrets expiring soon and a cursor which will be null only if all
448
   * results matching the criteria have been returned, and otherwise should be passed into the next
449
   * call to this method.
450
   */
451
  @Timed @ExceptionMetered
452
  @Path("expiring/v4")
453
  @GET
454
  @Produces(APPLICATION_JSON)
455
  @LogArguments
456
  public SanitizedSecretWithGroupsListAndCursor secretListingExpiringV4(
457
      @Auth AutomationClient automationClient,
458
      @QueryParam("minTime")  Long minTime,
459
      @QueryParam("maxTime") Long maxTime,
460
      @QueryParam("limit") Integer limit,
461
      @QueryParam("cursor") String cursor) {
462
    permissionCheck.checkAllowedForTargetTypeOrThrow(automationClient, Action.READ, SanitizedSecretWithGroupsListAndCursor.class);
1✔
463

464
    SecretRetrievalCursor cursorDecoded = null;
1✔
465
    if (cursor != null) {
1✔
466
      cursorDecoded = SecretRetrievalCursor.fromUrlEncodedString(cursor);
1✔
467
    }
468
    return secretControllerReadOnly.getSanitizedSecretsWithGroupsAndCursor(minTime, maxTime, limit,
1✔
469
        cursorDecoded);
470
  }
471

472
  /**
473
   * Backfill expiration for this secret.
474
   */
475
  @Timed @ExceptionMetered
476
  @Path("{name}/backfill-expiration")
477
  @POST
478
  @Consumes(APPLICATION_JSON)
479
  @Produces(APPLICATION_JSON)
480
  @LogArguments
481
  public boolean backfillExpiration(@Auth AutomationClient automationClient, @PathParam("name") String name, List<String> passwords) {
482
    Optional<Secret> secretOptional = secretController.getSecretByName(name);
1✔
483
    if (!secretOptional.isPresent()) {
1✔
484
      throw new NotFoundException("No such secret: " + name);
×
485
    }
486

487
    Secret secret = secretOptional.get();
1✔
488
    permissionCheck.checkAllowedOrThrow(automationClient, Action.UPDATE, secret);
1✔
489

490
    Optional<Instant> existingExpiry = Optional.empty();
1✔
491
    if (secret.getExpiry() > 0) {
1✔
492
      existingExpiry = Optional.of(Instant.ofEpochMilli(secret.getExpiry()*1000));
×
493
    }
494

495
    String secretName = secret.getName();
1✔
496
    byte[] secretContent = Base64.getDecoder().decode(secret.getSecret());
1✔
497

498
    // Always try empty password
499
    passwords.add("");
1✔
500

501
    Instant expiry = null;
1✔
502
    if (secretName.endsWith(".crt") || secretName.endsWith(".pem") || secretName.endsWith(".key")) {
1✔
503
      expiry = ExpirationExtractor.expirationFromEncodedCertificateChain(secretContent);
1✔
504
    } else if (secretName.endsWith(".gpg") || secretName.endsWith(".pgp")) {
1✔
505
      expiry = ExpirationExtractor.expirationFromOpenPGP(secretContent);
1✔
506
    } else if (secretName.endsWith(".p12") || secretName.endsWith(".pfx")) {
1✔
507
      while (expiry == null && !passwords.isEmpty()) {
1✔
508
        String password = passwords.remove(0);
1✔
509
        expiry = ExpirationExtractor.expirationFromKeystore("PKCS12", password, secretContent);
1✔
510
      }
1✔
511
    } else if (secretName.endsWith(".jceks")) {
1✔
512
      while (expiry == null && !passwords.isEmpty()) {
1✔
513
        String password = passwords.remove(0);
1✔
514
        expiry = ExpirationExtractor.expirationFromKeystore("JCEKS", password, secretContent);
1✔
515
      }
1✔
516
    } else if (secretName.endsWith(".jks")) {
×
517
      while (expiry == null && !passwords.isEmpty()) {
×
518
        String password = passwords.remove(0);
×
519
        expiry = ExpirationExtractor.expirationFromKeystore("JKS", password, secretContent);
×
520
      }
×
521
    }
522

523
    if (expiry != null) {
1✔
524
      if (existingExpiry.isPresent()) {
1✔
525
        long offset = existingExpiry.get().until(expiry, HOURS);
×
526
        if (offset > 24 || offset < -24) {
×
527
          logger.warn(
×
528
              "Extracted expiration of secret {} differs from actual by more than {} hours (extracted = {}, database = {}).",
529
              secretName, offset, expiry, existingExpiry.get());
×
530
        }
531

532
        // Do not overwrite existing expiry, we just want to check for differences and warn.
533
        return true;
×
534
      }
535

536
      logger.info("Found expiry for secret {}: {}", secretName, expiry.getEpochSecond());
1✔
537
      boolean success = secretDAO.setExpiration(name, expiry);
1✔
538
      if (success) {
1✔
539
        Map<String, String> extraInfo = new HashMap<>();
1✔
540
        extraInfo.put("backfilled expiry", Long.toString(expiry.getEpochSecond()));
1✔
541
        auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_BACKFILLEXPIRY, automationClient.getName(), name, extraInfo));
1✔
542
      }
543
      return success;
1✔
544
    }
545

546
    logger.info("Unable to determine expiry for secret {}", secretName);
×
547
    return false;
×
548
  }
549

550
  /**
551
   * Backfill content hmac for this secret.
552
   */
553
  @Timed @ExceptionMetered
554
  @Path("{name}/backfill-hmac")
555
  @POST
556
  @Consumes(APPLICATION_JSON)
557
  @Produces(APPLICATION_JSON)
558
  @LogArguments
559
  public boolean backfillHmac(@Auth AutomationClient automationClient, @PathParam("name") String name) {
560
    Optional<SecretSeriesAndContent> secret = secretDAO.getSecretByName(name);
×
561

562
    if (!secret.isPresent()) {
×
563
      return false;
×
564
    }
565
    logger.info("backfill-hmac {}: processing secret", name);
×
566

567
    permissionCheck.checkAllowedOrThrow(automationClient, Action.UPDATE, secret.get());
×
568

569
    SecretContent secretContent = secret.get().content();
×
570
    if (!secretContent.hmac().isEmpty()) {
×
571
      return true; // No need to backfill
×
572
    }
573
    String hmac = cryptographer.computeHmac(cryptographer.decrypt(secretContent.encryptedContent()).getBytes(UTF_8), "hmackey");
×
574
    return secretSeriesDAO.setHmac(secretContent.id(), hmac) == 1; // We expect only one row to be changed
×
575
  }
576

577
  /**
578
   * Retrieve listing of secrets expiring soon in a group
579
   *
580
   * @param time timestamp for farthest expiry to include
581
   * @param name Group name
582
   * responseMessage 200 List of secrets expiring soon in group
583
   */
584
  @Timed @ExceptionMetered
585
  @Path("expiring/{time}/{name}")
586
  @GET
587
  @Produces(APPLICATION_JSON)
588
  @LogArguments
589
  public Iterable<String> secretListingExpiringForGroup(@Auth AutomationClient automationClient,
590
      @PathParam("time") Long time, @PathParam("name") String name) {
591
    Group group = groupDAO.getGroup(name).orElseThrow(NotFoundException::new);
1✔
592
    permissionCheck.checkAllowedOrThrow(automationClient, Action.READ, group);
1✔
593

594
    List<SanitizedSecret> secrets = secretControllerReadOnly.getSanitizedSecrets(time, group);
1✔
595
    return secrets.stream()
1✔
596
        .map(SanitizedSecret::name)
1✔
597
        .collect(toSet());
1✔
598
  }
599

600
  /**
601
   * Retrieve information on a secret series
602
   *
603
   * @param name Secret series name
604
   *
605
   * responseMessage 200 Secret series information retrieved
606
   * responseMessage 404 Secret series not found
607
   */
608
  @Timed @ExceptionMetered
609
  @GET
610
  @Path("{name}")
611
  @Produces(APPLICATION_JSON)
612
  @LogArguments
613
  public SecretDetailResponseV2 secretInfo(@Auth AutomationClient automationClient,
614
      @PathParam("name") String name) {
615
    SecretSeriesAndContent secret = secretDAO.getSecretByName(name)
1✔
616
        .orElseThrow(NotFoundException::new);
1✔
617
    permissionCheck.checkAllowedOrThrow(automationClient, Action.READ, secret);
1✔
618

619
    return SecretDetailResponseV2.builder()
1✔
620
        .seriesAndContent(secret)
1✔
621
        .build();
1✔
622
  }
623

624
  /**
625
   * Retrieve information on a secret series
626
   *
627
   * @param name Secret series name
628
   *
629
   * responseMessage 200 Secret series information retrieved
630
   * responseMessage 404 Secret series not found
631
   */
632
  @Timed @ExceptionMetered
633
  @GET
634
  @Path("{name}/sanitized")
635
  @Produces(APPLICATION_JSON)
636
  @LogArguments
637
  public SanitizedSecret getSanitizedSecret(@Auth AutomationClient automationClient,
638
      @PathParam("name") String name) {
639
    SecretSeriesAndContent secretSeriesAndContent = secretDAO.getSecretByName(name)
1✔
640
        .orElseThrow(NotFoundException::new);
1✔
641
    permissionCheck.checkAllowedOrThrow(automationClient, Action.READ, secretSeriesAndContent);
1✔
642

643
    return SanitizedSecret.fromSecretSeriesAndContent(secretSeriesAndContent);
1✔
644
  }
645

646
  /**
647
   * Retrieve contents for a set of secret series.  Throws an exception
648
   * for unexpected errors (i. e. empty secret names or errors connecting to
649
   * the database); returns a response containing the contents of found
650
   * secrets and a list of any missing secrets.
651
   *
652
   *
653
   * responseMessage 200 Secret series information retrieved
654
   */
655
  @Timed @ExceptionMetered
656
  @POST
657
  @Path("request/contents")
658
  @Produces(APPLICATION_JSON)
659
  @LogArguments
660
  public SecretContentsResponseV2 secretContents(@Auth AutomationClient automationClient,
661
      @Valid SecretContentsRequestV2 request) {
662
    HashMap<String, String> successSecrets = new HashMap<>();
1✔
663
    ArrayList<String> missingSecrets = new ArrayList<>();
1✔
664

665
    // Get the contents for each secret, recording any errors
666
    for (String secretName : request.secrets()) {
1✔
667
      // Get the secret, if present
668
      Optional<Secret> secret = secretController.getSecretByName(secretName);
1✔
669

670
      if (!secret.isPresent()) {
1✔
671
        missingSecrets.add(secretName);
1✔
672
      } else {
673
        permissionCheck.checkAllowedOrThrow(automationClient, Action.READ, secret.get());
1✔
674
        successSecrets.put(secretName, secret.get().getSecret());
1✔
675
      }
676
    }
1✔
677

678
    // Record the read in the audit log, tracking which secrets were found and not found
679
    Map<String, String> extraInfo = new HashMap<>();
1✔
680
    extraInfo.put("success_secrets", successSecrets.keySet().toString());
1✔
681
    extraInfo.put("missing_secrets", missingSecrets.toString());
1✔
682
    auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_READCONTENT, automationClient.getName(), request.secrets().toString(), extraInfo));
1✔
683

684
    return SecretContentsResponseV2.builder()
1✔
685
        .successSecrets(successSecrets)
1✔
686
        .missingSecrets(missingSecrets)
1✔
687
        .build();
1✔
688
  }
689

690
  /**
691
   * Retrieve the given range of versions of this secret, sorted from newest to
692
   * oldest update time.  If versionIdx is nonzero, then numVersions versions,
693
   * starting from versionIdx in the list and increasing in index, will be
694
   * returned (set numVersions to a very large number to retrieve all versions).
695
   * For instance, versionIdx = 5 and numVersions = 10 will retrieve entries
696
   * at indices 5 through 14.
697
   *
698
   * @param name Secret series name
699
   * @param versionIdx The index in the list of versions of the first version to retrieve
700
   * @param numVersions The number of versions to retrieve
701
   *
702
   * responseMessage 200 Secret series information retrieved
703
   * responseMessage 404 Secret series not found
704
   */
705
  @Timed @ExceptionMetered
706
  @GET
707
  @Path("{name}/versions")
708
  @Produces(APPLICATION_JSON)
709
  @LogArguments
710
  public Iterable<SecretDetailResponseV2> secretVersions(@Auth AutomationClient automationClient,
711
      @PathParam("name") String name, @QueryParam("versionIdx") int versionIdx,
712
      @QueryParam("numVersions") int numVersions) {
713
    Secret secret = secretControllerReadOnly.getSecretByName(name)
1✔
714
        .orElseThrow(NotFoundException::new);
1✔
715
    permissionCheck.checkAllowedOrThrow(automationClient, Action.READ, secret);
1✔
716

717
    ImmutableList<SanitizedSecret> versions =
1✔
718
        secretDAO.getSecretVersionsByName(name, versionIdx, numVersions)
1✔
719
            .orElseThrow(NotFoundException::new);
1✔
720

721
    return versions.stream()
1✔
722
        .map(v -> SecretDetailResponseV2.builder()
1✔
723
            .sanitizedSecret(v)
1✔
724
            .build())
1✔
725
        .collect(toList());
1✔
726
  }
727

728

729
  /**
730
   * Reset the current version of the given secret to the given version index.
731
   *
732
   * @param request A request to update a given secret
733
   *
734
   * responseMessage 201 Secret series current version updated successfully
735
   * responseMessage 400 Invalid secret version specified
736
   * responseMessage 404 Secret series not found
737
   */
738
  @Timed @ExceptionMetered
739
  @Path("{name}/setversion")
740
  @POST
741
  @LogArguments
742
  public Response resetSecretVersion(@Auth AutomationClient automationClient,
743
      @Valid SetSecretVersionRequestV2 request) {
744
    SecretSeries secretSeries = secretSeriesDAO.getSecretSeriesByName(request.name()).orElseThrow(
1✔
745
        NotFoundException::new);
746
    permissionCheck.checkAllowedOrThrow(automationClient, Action.UPDATE, secretSeries);
1✔
747

748
    secretDAO.setCurrentSecretVersionByName(request.name(), request.version(),
1✔
749
        automationClient.getName());
1✔
750

751
    // If the secret wasn't found or the request was misformed, setCurrentSecretVersionByName
752
    // already threw an exception
753
    Map<String, String> extraInfo = new HashMap<>();
1✔
754
    extraInfo.put("new version", Long.toString(request.version()));
1✔
755
    auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_CHANGEVERSION,
1✔
756
        automationClient.getName(), request.name(), extraInfo));
1✔
757

758
    return Response.status(Response.Status.CREATED).build();
1✔
759
  }
760

761
  /**
762
   * Listing of groups a secret is assigned to
763
   *
764
   * @param name Secret series name
765
   *
766
   * responseMessage 200 Listing succeeded
767
   * responseMessage 404 Secret series not found
768
   */
769
  @Timed @ExceptionMetered
770
  @GET
771
  @Path("{name}/groups")
772
  @Produces(APPLICATION_JSON)
773
  @LogArguments
774
  public Iterable<String> secretGroupsListing(@Auth AutomationClient automationClient,
775
      @PathParam("name") String name) {
776
    // TODO: Use latest version instead of non-versioned
777
    Secret secret = secretControllerReadOnly.getSecretByName(name)
1✔
778
        .orElseThrow(NotFoundException::new);
1✔
779
    permissionCheck.checkAllowedOrThrow(automationClient, Action.READ, secret);
1✔
780

781
    return aclDAO.getGroupsFor(secret).stream()
1✔
782
        .map(Group::getName)
1✔
783
        .collect(toSet());
1✔
784
  }
785

786
  /**
787
   * Modify the groups a secret is assigned to
788
   *
789
   * @param name Secret series name
790
   * @param request JSON request to modify groups
791
   *
792
   * responseMessage 201 Group membership changed
793
   * responseMessage 404 Secret series not found
794
   */
795
  @Timed @ExceptionMetered
796
  @PUT
797
  @Path("{name}/groups")
798
  @Consumes(APPLICATION_JSON)
799
  @Produces(APPLICATION_JSON)
800
  @LogArguments
801
  public Iterable<String> modifySecretGroups(@Auth AutomationClient automationClient,
802
      @PathParam("name") String name, @Valid ModifyGroupsRequestV2 request) {
803
    // TODO: Use latest version instead of non-versioned
804
    Secret secret = secretController.getSecretByName(name)
1✔
805
        .orElseThrow(NotFoundException::new);
1✔
806
    permissionCheck.checkAllowedOrThrow(automationClient, Action.UPDATE, secret);
1✔
807

808
    String user = automationClient.getName();
1✔
809

810
    long secretId = secret.getId();
1✔
811
    Set<String> oldGroups = aclDAO.getGroupsFor(secret).stream()
1✔
812
        .map(Group::getName)
1✔
813
        .collect(toSet());
1✔
814

815
    Set<String> groupsToAdd = Sets.difference(request.addGroups(), oldGroups);
1✔
816
    Set<String> groupsToRemove = Sets.intersection(request.removeGroups(), oldGroups);
1✔
817

818
    // TODO: should optimize AclDAO to use names and return only name column
819

820
    groupsToGroupIds(groupsToAdd)
1✔
821
        .forEach((maybeGroupId) -> maybeGroupId.ifPresent(
1✔
822
            (groupId) -> aclDAO.findAndAllowAccess(secretId, groupId, auditLog, user, new HashMap<>())));
1✔
823

824
    groupsToGroupIds(groupsToRemove)
1✔
825
        .forEach((maybeGroupId) -> maybeGroupId.ifPresent(
1✔
826
            (groupId) -> aclDAO.findAndRevokeAccess(secretId, groupId, auditLog, user, new HashMap<>())));
1✔
827

828
    return aclDAO.getGroupsFor(secret).stream()
1✔
829
        .map(Group::getName)
1✔
830
        .collect(toSet());
1✔
831
  }
832

833
  @Timed
834
  @ExceptionMetered
835
  @GET
836
  @Path("/deleted/{name}")
837
  @LogArguments
838
  public List<SecretSeries> getDeletedSecretSeriesByName(
839
    @Auth AutomationClient automationClient,
840
    @PathParam("name") String name) {
841
    return secretDAO.getSecretsWithDeletedName(name);
1✔
842
  }
843

844
  /**
845
   * Delete a secret series
846
   *
847
   * @param name Secret series name
848
   *
849
   * responseMessage 204 Secret series deleted
850
   * responseMessage 404 Secret series not found
851
   */
852
  @Timed @ExceptionMetered
853
  @DELETE
854
  @Path("{name}")
855
  @LogArguments
856
  public Response deleteSecretSeries(
857
      @Auth AutomationClient automationClient,
858
      @PathParam("name") String name,
859
      @QueryParam("deletionMode") @NullOrValidEnumIgnoreCase(SecretDeletionMode.class) String deletionMode) {
860

861
    SecretDeletionMode mode = (deletionMode == null)
1✔
862
        ? SecretDeletionMode.SOFT
1✔
863
        : SecretDeletionMode.valueOfIgnoreCase(deletionMode);
1✔
864

865
    Secret secret = secretController.getSecretByName(name).orElseThrow(() -> new NotFoundException("Secret series not found."));
1✔
866
    permissionCheck.checkAllowedOrThrow(automationClient, Action.DELETE, secret);
1✔
867

868
    // Get the groups for this secret so they can be restored manually if necessary
869
    Set<String> groups = aclDAO.getGroupsFor(secret).stream().map(Group::getName).collect(toSet());
1✔
870

871
    secretDAO.deleteSecretsByName(name, mode);
1✔
872

873
    // Record the deletion in the audit log
874
    Map<String, String> extraInfo = new HashMap<>();
1✔
875
    extraInfo.put("groups", groups.toString());
1✔
876
    extraInfo.put("current version", secret.getVersion().toString());
1✔
877
    extraInfo.put("deletion mode", mode.toString());
1✔
878
    auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_DELETE, automationClient.getName(), name, extraInfo));
1✔
879
    return Response.noContent().build();
1✔
880
  }
881

882
  private Stream<Optional<Long>> groupsToGroupIds(Set<String> groupNames) {
883
    return groupNames.stream()
1✔
884
        .map(groupDAO::getGroup)
1✔
885
        .map((group) -> group.map(Group::getId));
1✔
886
  }
887

888
  private boolean secretOwnerNotProvided(String secretOwner) {
889
    return secretOwner == null || secretOwner.isEmpty();
1✔
890
  }
891

892
  private boolean shouldInferSecretOwnerUponCreation() {
893
    return config.getNewSecretOwnershipStrategy() == KeywhizConfig.NewSecretOwnershipStrategy.INFER_FROM_CLIENT;
1✔
894
  }
895

896
  private String findClientGroup(AutomationClient automationClient) {
897
      Set<Group> clientGroups = aclDAO.getGroupsFor(automationClient);
1✔
898
      if (clientGroups.size() == 0) {
1✔
899
        logger.warn(String.format("Client %s does not belong to any group.", automationClient));
1✔
900
      } else if (clientGroups.size() == 1) {
1✔
901
        Group clientGroup = clientGroups.stream().findFirst().get();
1✔
902
        return clientGroup.getName();
1✔
903
      } else {
904
        String groups = new ArrayList<>(clientGroups).toString();
1✔
905
        logger.warn(String.format("Client %s belongs to more than one group: %s",
1✔
906
            automationClient,
907
            groups));
908
      }
909
      return null;
1✔
910
  }
911

912
  private String getSecretOwnerForSecretCreation(String secretOwner, AutomationClient automationClient) {
913
    if (secretOwnerNotProvided(secretOwner) && shouldInferSecretOwnerUponCreation()) {
1✔
914
      return findClientGroup(automationClient);
1✔
915
    }
916
    return secretOwner;
1✔
917
  }
918
}
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

© 2026 Coveralls, Inc