Skip to content

Commit

Permalink
UID2-3506 Add functionality on oncall page to list related keysets an…
Browse files Browse the repository at this point in the history
…d rotate them (#329)

* Add backend change for listing related keysets

* Improve logic on backend filtering

* Add frontend to highlight related keysets

* Add functionality to rotate keysets

* Get ClientTypes from backend

* Remove condition for force check

* Check if a site has any client key that has ID_READER role

* Add helper text to explain min age and force option

* Check ID_READER role with correct type

* Add tests for related keyset api

* Update comments

* Update highlights for client types

* Revert changes for keyset.json

* Revert unused functions

* Revert unused clock

* Add changes to show rotation result

* Update `min_age_seconds` to 1

* Use `Collections.disjoint` instead of customised `ContainAny`

* Modify logic for verifying site id

* Add comments for moving checking keyset logic to shared

* Fix logic of disjoint

* Add prompt to confirm rotation

* Update wordings for the note
  • Loading branch information
cYKatherine authored Aug 8, 2024
1 parent c84487e commit 15f3771
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 13 deletions.
2 changes: 1 addition & 1 deletion src/main/java/com/uid2/admin/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ public void run() {
new EnclaveIdService(auth, writeLock, enclaveStoreWriter, enclaveIdProvider, clock),
encryptionKeyService,
new KeyAclService(auth, writeLock, keyAclStoreWriter, keyAclProvider, siteProvider, encryptionKeyService),
new SharingService(auth, writeLock, adminKeysetProvider, keysetManager, siteProvider, enableKeysets),
new SharingService(auth, writeLock, adminKeysetProvider, keysetManager, siteProvider, enableKeysets, clientKeyProvider),
clientSideKeypairService,
new ServiceService(auth, writeLock, serviceStoreWriter, serviceProvider, siteProvider, serviceLinkProvider),
new ServiceLinkService(auth, writeLock, serviceLinkStoreWriter, serviceLinkProvider, serviceProvider, siteProvider),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.uid2.shared.store.parser.Parser;
import com.uid2.shared.store.parser.ParsingResult;
import com.uid2.shared.Utils;
import com.uid2.shared.auth.KeysetSnapshot;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;

Expand Down
65 changes: 64 additions & 1 deletion src/main/java/com/uid2/admin/vertx/service/SharingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

import com.uid2.admin.auth.AdminAuthMiddleware;
import com.uid2.admin.auth.AdminKeyset;
import com.uid2.admin.legacy.LegacyClientKey;
import com.uid2.admin.legacy.RotatingLegacyClientKeyProvider;
import com.uid2.admin.store.reader.RotatingAdminKeysetStore;
import com.uid2.admin.vertx.RequestUtil;
import com.uid2.admin.vertx.WriteLock;
import com.uid2.admin.managers.KeysetManager;
import com.uid2.admin.vertx.ResponseUtil;
import com.uid2.shared.Const;
import com.uid2.shared.auth.KeysetSnapshot;
import com.uid2.shared.auth.Role;
import com.uid2.shared.model.ClientType;
import com.uid2.shared.model.SiteUtil;
Expand All @@ -30,6 +34,7 @@ public class SharingService implements IService {
private final RotatingAdminKeysetStore keysetProvider;
private final RotatingSiteStore siteProvider;
private final KeysetManager keysetManager;
private final RotatingLegacyClientKeyProvider clientKeyProvider;
private static final Logger LOGGER = LoggerFactory.getLogger(SharingService.class);

private final boolean enableKeysets;
Expand All @@ -39,13 +44,15 @@ public SharingService(AdminAuthMiddleware auth,
RotatingAdminKeysetStore keysetProvider,
KeysetManager keysetManager,
RotatingSiteStore siteProvider,
boolean enableKeyset) {
boolean enableKeyset,
RotatingLegacyClientKeyProvider clientKeyProvider) {
this.auth = auth;
this.writeLock = writeLock;
this.keysetProvider = keysetProvider;
this.keysetManager = keysetManager;
this.siteProvider = siteProvider;
this.enableKeysets = enableKeyset;
this.clientKeyProvider = clientKeyProvider;
}

@Override
Expand All @@ -70,6 +77,9 @@ public void setupRoutes(Router router) {
router.get("/api/sharing/keyset/:keyset_id").handler(
auth.handle(this::handleListKeyset, Role.MAINTAINER)
);
router.get("/api/sharing/keysets/related").handler(
auth.handle(this::handleListAllKeysetsRelated, Role.MAINTAINER)
);
}

private void handleSetKeyset(RoutingContext rc) {
Expand Down Expand Up @@ -150,6 +160,59 @@ private void handleSetKeyset(RoutingContext rc) {
}
}

private void handleListAllKeysetsRelated(RoutingContext rc) {
try {
// Get value for site id
final Optional<Integer> siteIdOpt = RequestUtil.getSiteId(rc, "site_id");
if (!siteIdOpt.isPresent()) {
ResponseUtil.error(rc, 400, "must specify a site id");
return;
}
final int siteId = siteIdOpt.get();

if (!SiteUtil.isValidSiteId(siteId)) {
ResponseUtil.error(rc, 400, "must specify a valid site id");
return;
}

// Get value for client type from the backend
Set<ClientType> clientTypes = this.siteProvider.getSite(siteId).getClientTypes();

// Check if this site has any client key that has an ID_READER role
boolean isIdReaderRole = false;
for (LegacyClientKey c : this.clientKeyProvider.getAll()) {
if (c.getRoles().contains(Role.ID_READER)) {
isIdReaderRole = true;
}
}

// Get the keyset ids that need to be rotated
final JsonArray ja = new JsonArray();
Map<Integer, AdminKeyset> collection = this.keysetProvider.getSnapshot().getAllKeysets();
for (Map.Entry<Integer, AdminKeyset> keyset : collection.entrySet()) {
// The keysets meet any of the below conditions ALL need to be rotated:
// a. Keysets where allowed_types include any of the clientTypes of the site
// b. If this participant has a client key with ID_READER role, we want to rotate all the keysets where allowed_sites is set to null
// c. Keysets where allowed_sites include the leaked site
// d. Keysets belonging to the leaked site itself
if (!Collections.disjoint(keyset.getValue().getAllowedTypes(), clientTypes) ||
isIdReaderRole && keyset.getValue().getAllowedSites() == null ||
keyset.getValue().getAllowedSites() != null && keyset.getValue().getAllowedSites().contains(siteId) ||
keyset.getValue().getSiteId() == siteId) {
// TODO: We have functions below which check if a keysetkey is accessible by a client. We should move the logic of checking keyset to shared as well.
// https://github.com/IABTechLab/uid2-shared/blob/19edb010c6a4d753d03c89268c238be10a8f6722/src/main/java/com/uid2/shared/auth/KeysetSnapshot.java#L13
ja.add(jsonFullKeyset(keyset.getValue()));
}
}

rc.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end(ja.encode());
} catch (Exception e) {
rc.fail(500, e);
}
}

// Returns if a keyset is one of the reserved ones
private static boolean isSpecialKeyset(int keysetId) {
return keysetId == Const.Data.MasterKeysetId || keysetId == Const.Data.RefreshKeysetId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ private static void assertAddedClientKeyEquals(ClientKey expected, ClientKey act
.isEqualTo(expected);
}

private static class LegacyClientBuilder {
public static class LegacyClientBuilder {
private String name = "test_client";
private String contact = "test_contact";
private int siteId = 999;
Expand Down
159 changes: 152 additions & 7 deletions src/test/java/com/uid2/admin/vertx/SharingServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,19 @@
import org.junit.jupiter.params.provider.ValueSource;

import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.*;

public class SharingServiceTest extends ServiceTestBase {
@Override
protected IService createService() {
KeysetManager keysetManager = new KeysetManager(adminKeysetProvider, adminKeysetWriter, keysetKeyManager, true);
return new SharingService(auth, writeLock, adminKeysetProvider, keysetManager, siteProvider, true);
return new SharingService(auth, writeLock, adminKeysetProvider, keysetManager, siteProvider, true, clientKeyProvider);
}

private void compareKeysetListToResult(AdminKeyset keyset, JsonArray actualList) {
Expand Down Expand Up @@ -1259,4 +1255,153 @@ void KeysetSetNewWithType(Vertx vertx, VertxTestContext testContext) {
testContext.completeNow();
});
}

@Test
void RelatedKeysetSetsWithClientTypes(Vertx vertx, VertxTestContext testContext) {
fakeAuth(Role.MAINTAINER);

AdminKeyset adminKeyset1 = new AdminKeyset(3, 5, "test", Set.of(4,6,7), Instant.now().getEpochSecond(),true, true, new HashSet<>(Arrays.asList(ClientType.DSP, ClientType.PUBLISHER)));
AdminKeyset adminKeyset2 = new AdminKeyset(4, 7, "test", Set.of(12), Instant.now().getEpochSecond(),true, true, new HashSet<>(Arrays.asList(ClientType.DSP)));
AdminKeyset adminKeyset3 = new AdminKeyset(5, 4, "test", Set.of(5), Instant.now().getEpochSecond(),true, true, new HashSet<>());

Map<Integer, AdminKeyset> keysets = new HashMap<Integer, AdminKeyset>() {{
put(3, adminKeyset1);
put(4, adminKeyset2);
put(5, adminKeyset3);
}};

setAdminKeysets(keysets);
mockSiteExistence(5, 7, 4, 8, 22, 25, 6);
doReturn(new Site(8, "test-name", true, new HashSet<>(Arrays.asList(ClientType.DSP)), null)).when(siteProvider).getSite(8);


get(vertx, testContext, "/api/sharing/keysets/related?site_id=8", response -> {
assertEquals(200, response.statusCode());

Set<Integer> expectedKeysetIds = new HashSet<>(Arrays.asList(adminKeyset1.getKeysetId(), adminKeyset2.getKeysetId()));

Set<Integer> actualKeysetIds = new HashSet<>();
JsonArray responseArray = response.bodyAsJsonArray();
for (int i = 0; i < responseArray.size(); i++) {
JsonObject item = responseArray.getJsonObject(i);
int keysetId = item.getInteger("keyset_id");
actualKeysetIds.add(keysetId);
}
assertEquals(true, actualKeysetIds.containsAll(expectedKeysetIds));
testContext.completeNow();
});
}

@Test
void RelatedKeysetSetsWithAllowedSites(Vertx vertx, VertxTestContext testContext) {
fakeAuth(Role.MAINTAINER);

AdminKeyset adminKeyset1 = new AdminKeyset(3, 1, "test", Set.of(4,8), Instant.now().getEpochSecond(),true, true, new HashSet<>());
AdminKeyset adminKeyset2 = new AdminKeyset(4, 2, "test", Set.of(5,8), Instant.now().getEpochSecond(),true, true, new HashSet<>());
AdminKeyset adminKeyset3 = new AdminKeyset(5, 3, "test", Set.of(6), Instant.now().getEpochSecond(),true, true, new HashSet<>());

Map<Integer, AdminKeyset> keysets = new HashMap<Integer, AdminKeyset>() {{
put(3, adminKeyset1);
put(4, adminKeyset2);
put(5, adminKeyset3);
}};

setAdminKeysets(keysets);
mockSiteExistence(1,2,3,4,5,6,8);
doReturn(new Site(8, "test-name", true,null)).when(siteProvider).getSite(8);


get(vertx, testContext, "/api/sharing/keysets/related?site_id=8", response -> {
assertEquals(200, response.statusCode());

Set<Integer> expectedKeysetIds = new HashSet<>(Arrays.asList(adminKeyset1.getKeysetId(), adminKeyset2.getKeysetId()));

Set<Integer> actualKeysetIds = new HashSet<>();
JsonArray responseArray = response.bodyAsJsonArray();
for (int i = 0; i < responseArray.size(); i++) {
JsonObject item = responseArray.getJsonObject(i);
int keysetId = item.getInteger("keyset_id");
actualKeysetIds.add(keysetId);
}
assertEquals(true, actualKeysetIds.containsAll(expectedKeysetIds));
testContext.completeNow();
});
}

@Test
void RelatedKeysetSetsWithSameSiteId(Vertx vertx, VertxTestContext testContext) {
fakeAuth(Role.MAINTAINER);

AdminKeyset adminKeyset1 = new AdminKeyset(3, 1, "test", Set.of(4), Instant.now().getEpochSecond(),true, true, new HashSet<>());
AdminKeyset adminKeyset2 = new AdminKeyset(4, 2, "test", Set.of(5), Instant.now().getEpochSecond(),true, true, new HashSet<>());
AdminKeyset adminKeyset3 = new AdminKeyset(5, 8, "test", Set.of(6), Instant.now().getEpochSecond(),true, true, new HashSet<>());

Map<Integer, AdminKeyset> keysets = new HashMap<Integer, AdminKeyset>() {{
put(3, adminKeyset1);
put(4, adminKeyset2);
put(5, adminKeyset3);
}};

setAdminKeysets(keysets);
mockSiteExistence(1,2,4,5,6,8);
doReturn(new Site(8, "test-name", true,null)).when(siteProvider).getSite(8);


get(vertx, testContext, "/api/sharing/keysets/related?site_id=8", response -> {
assertEquals(200, response.statusCode());

Set<Integer> expectedKeysetIds = new HashSet<>(Arrays.asList(adminKeyset3.getKeysetId()));

Set<Integer> actualKeysetIds = new HashSet<>();
JsonArray responseArray = response.bodyAsJsonArray();
for (int i = 0; i < responseArray.size(); i++) {
JsonObject item = responseArray.getJsonObject(i);
int keysetId = item.getInteger("keyset_id");
actualKeysetIds.add(keysetId);
}
assertEquals(true, actualKeysetIds.containsAll(expectedKeysetIds));
testContext.completeNow();
});
}

@Test
void RelatedKeysetSetsWithAllowSiteNull(Vertx vertx, VertxTestContext testContext) {
fakeAuth(Role.MAINTAINER);

AdminKeyset adminKeyset1 = new AdminKeyset(3, 1, "test", Set.of(4), Instant.now().getEpochSecond(),true, true, new HashSet<>());
AdminKeyset adminKeyset2 = new AdminKeyset(4, 2, "test", Set.of(5), Instant.now().getEpochSecond(),true, true, new HashSet<>());
AdminKeyset adminKeyset3 = new AdminKeyset(5, 3, "test", null, Instant.now().getEpochSecond(),true, true, new HashSet<>());

Map<Integer, AdminKeyset> keysets = new HashMap<Integer, AdminKeyset>() {{
put(3, adminKeyset1);
put(4, adminKeyset2);
put(5, adminKeyset3);
}};

setAdminKeysets(keysets);
mockSiteExistence(1,2,3,4,5,8);
doReturn(new Site(8, "test-name", true,null)).when(siteProvider).getSite(8);
setClientKeys(
new ClientKeyServiceTest.LegacyClientBuilder()
.withRoles(new HashSet<>(Arrays.asList(Role.ID_READER)))
.withSiteId(8)
.build());


get(vertx, testContext, "/api/sharing/keysets/related?site_id=8", response -> {
assertEquals(200, response.statusCode());

Set<Integer> expectedKeysetIds = new HashSet<>(Arrays.asList(adminKeyset3.getKeysetId()));

Set<Integer> actualKeysetIds = new HashSet<>();
JsonArray responseArray = response.bodyAsJsonArray();
for (int i = 0; i < responseArray.size(); i++) {
JsonObject item = responseArray.getJsonObject(i);
int keysetId = item.getInteger("keyset_id");
actualKeysetIds.add(keysetId);
}
assertEquals(true, actualKeysetIds.containsAll(expectedKeysetIds));
testContext.completeNow();
});
}
}
26 changes: 26 additions & 0 deletions webroot/adm/oncall/participant-summary.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,32 @@ <h5>Participant Opt-out Webhook</h5>
<pre id="webhooksStandardOutput"></pre>
</div>
</div>
<div class="row px-2">
<div class="col section">
<h5>Participant Related Keysets</h5>
<div><b>A keyset is related to the participant if it matches below criteria:</b><br>
a. Keysets where allowed_types include any of the clientTypes of the site<br>
b. If this participant has a client key with ID_READER role, we want to rotate all the keysets where allowed_sites is set to null<br>
c. Keysets where allowed_sites include the leaked site<br>
d. Keysets belonging to the leaked site itself<br></div>
<pre class="errorDiv" id="relatedKeysetsErrorOutput"></pre>
<pre id="relatedKeysetsStandardOutput"></pre>

<div class="col">
<a href="#" class="btn btn-primary" id="doRotateKeysets">Rotate Keysets</a>
<div>
Normally, keys don't become active for 24 hours when rotated, which gives participants 24 hours before they need to call sdk.refresh().
However in this case, rotation will make the new keys active immediately.
This means participants will not be able to decrypt newly created UID tokens until they have called sdk.refresh().
Note that we recommend calling sdk.refresh() once per hour (see <a href="https://unifiedid.com/docs/getting-started/gs-faqs#where-do-i-get-the-decryption-keys">documentation</a>)
</div>
<div id="output">
<pre id="rotateKeysetsErrorOutput"></pre>
<pre id="rotateKeysetsStandardOutput"></pre>
</div>
</div>
</div>
</div>
</div>
</div>

Expand Down
Loading

0 comments on commit 15f3771

Please sign in to comment.