Skip to content

Commit 15f3771

Browse files
authored
UID2-3506 Add functionality on oncall page to list related keysets and 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
1 parent c84487e commit 15f3771

File tree

7 files changed

+307
-13
lines changed

7 files changed

+307
-13
lines changed

src/main/java/com/uid2/admin/Main.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ public void run() {
251251
new EnclaveIdService(auth, writeLock, enclaveStoreWriter, enclaveIdProvider, clock),
252252
encryptionKeyService,
253253
new KeyAclService(auth, writeLock, keyAclStoreWriter, keyAclProvider, siteProvider, encryptionKeyService),
254-
new SharingService(auth, writeLock, adminKeysetProvider, keysetManager, siteProvider, enableKeysets),
254+
new SharingService(auth, writeLock, adminKeysetProvider, keysetManager, siteProvider, enableKeysets, clientKeyProvider),
255255
clientSideKeypairService,
256256
new ServiceService(auth, writeLock, serviceStoreWriter, serviceProvider, siteProvider, serviceLinkProvider),
257257
new ServiceLinkService(auth, writeLock, serviceLinkStoreWriter, serviceLinkProvider, serviceProvider, siteProvider),

src/main/java/com/uid2/admin/store/parser/AdminKeysetParser.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import com.uid2.shared.store.parser.Parser;
77
import com.uid2.shared.store.parser.ParsingResult;
88
import com.uid2.shared.Utils;
9-
import com.uid2.shared.auth.KeysetSnapshot;
109
import io.vertx.core.json.JsonArray;
1110
import io.vertx.core.json.JsonObject;
1211

src/main/java/com/uid2/admin/vertx/service/SharingService.java

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
import com.uid2.admin.auth.AdminAuthMiddleware;
44
import com.uid2.admin.auth.AdminKeyset;
5+
import com.uid2.admin.legacy.LegacyClientKey;
6+
import com.uid2.admin.legacy.RotatingLegacyClientKeyProvider;
57
import com.uid2.admin.store.reader.RotatingAdminKeysetStore;
8+
import com.uid2.admin.vertx.RequestUtil;
69
import com.uid2.admin.vertx.WriteLock;
710
import com.uid2.admin.managers.KeysetManager;
811
import com.uid2.admin.vertx.ResponseUtil;
912
import com.uid2.shared.Const;
13+
import com.uid2.shared.auth.KeysetSnapshot;
1014
import com.uid2.shared.auth.Role;
1115
import com.uid2.shared.model.ClientType;
1216
import com.uid2.shared.model.SiteUtil;
@@ -30,6 +34,7 @@ public class SharingService implements IService {
3034
private final RotatingAdminKeysetStore keysetProvider;
3135
private final RotatingSiteStore siteProvider;
3236
private final KeysetManager keysetManager;
37+
private final RotatingLegacyClientKeyProvider clientKeyProvider;
3338
private static final Logger LOGGER = LoggerFactory.getLogger(SharingService.class);
3439

3540
private final boolean enableKeysets;
@@ -39,13 +44,15 @@ public SharingService(AdminAuthMiddleware auth,
3944
RotatingAdminKeysetStore keysetProvider,
4045
KeysetManager keysetManager,
4146
RotatingSiteStore siteProvider,
42-
boolean enableKeyset) {
47+
boolean enableKeyset,
48+
RotatingLegacyClientKeyProvider clientKeyProvider) {
4349
this.auth = auth;
4450
this.writeLock = writeLock;
4551
this.keysetProvider = keysetProvider;
4652
this.keysetManager = keysetManager;
4753
this.siteProvider = siteProvider;
4854
this.enableKeysets = enableKeyset;
55+
this.clientKeyProvider = clientKeyProvider;
4956
}
5057

5158
@Override
@@ -70,6 +77,9 @@ public void setupRoutes(Router router) {
7077
router.get("/api/sharing/keyset/:keyset_id").handler(
7178
auth.handle(this::handleListKeyset, Role.MAINTAINER)
7279
);
80+
router.get("/api/sharing/keysets/related").handler(
81+
auth.handle(this::handleListAllKeysetsRelated, Role.MAINTAINER)
82+
);
7383
}
7484

7585
private void handleSetKeyset(RoutingContext rc) {
@@ -150,6 +160,59 @@ private void handleSetKeyset(RoutingContext rc) {
150160
}
151161
}
152162

163+
private void handleListAllKeysetsRelated(RoutingContext rc) {
164+
try {
165+
// Get value for site id
166+
final Optional<Integer> siteIdOpt = RequestUtil.getSiteId(rc, "site_id");
167+
if (!siteIdOpt.isPresent()) {
168+
ResponseUtil.error(rc, 400, "must specify a site id");
169+
return;
170+
}
171+
final int siteId = siteIdOpt.get();
172+
173+
if (!SiteUtil.isValidSiteId(siteId)) {
174+
ResponseUtil.error(rc, 400, "must specify a valid site id");
175+
return;
176+
}
177+
178+
// Get value for client type from the backend
179+
Set<ClientType> clientTypes = this.siteProvider.getSite(siteId).getClientTypes();
180+
181+
// Check if this site has any client key that has an ID_READER role
182+
boolean isIdReaderRole = false;
183+
for (LegacyClientKey c : this.clientKeyProvider.getAll()) {
184+
if (c.getRoles().contains(Role.ID_READER)) {
185+
isIdReaderRole = true;
186+
}
187+
}
188+
189+
// Get the keyset ids that need to be rotated
190+
final JsonArray ja = new JsonArray();
191+
Map<Integer, AdminKeyset> collection = this.keysetProvider.getSnapshot().getAllKeysets();
192+
for (Map.Entry<Integer, AdminKeyset> keyset : collection.entrySet()) {
193+
// The keysets meet any of the below conditions ALL need to be rotated:
194+
// a. Keysets where allowed_types include any of the clientTypes of the site
195+
// 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
196+
// c. Keysets where allowed_sites include the leaked site
197+
// d. Keysets belonging to the leaked site itself
198+
if (!Collections.disjoint(keyset.getValue().getAllowedTypes(), clientTypes) ||
199+
isIdReaderRole && keyset.getValue().getAllowedSites() == null ||
200+
keyset.getValue().getAllowedSites() != null && keyset.getValue().getAllowedSites().contains(siteId) ||
201+
keyset.getValue().getSiteId() == siteId) {
202+
// 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.
203+
// https://github.com/IABTechLab/uid2-shared/blob/19edb010c6a4d753d03c89268c238be10a8f6722/src/main/java/com/uid2/shared/auth/KeysetSnapshot.java#L13
204+
ja.add(jsonFullKeyset(keyset.getValue()));
205+
}
206+
}
207+
208+
rc.response()
209+
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
210+
.end(ja.encode());
211+
} catch (Exception e) {
212+
rc.fail(500, e);
213+
}
214+
}
215+
153216
// Returns if a keyset is one of the reserved ones
154217
private static boolean isSpecialKeyset(int keysetId) {
155218
return keysetId == Const.Data.MasterKeysetId || keysetId == Const.Data.RefreshKeysetId

src/test/java/com/uid2/admin/vertx/ClientKeyServiceTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ private static void assertAddedClientKeyEquals(ClientKey expected, ClientKey act
460460
.isEqualTo(expected);
461461
}
462462

463-
private static class LegacyClientBuilder {
463+
public static class LegacyClientBuilder {
464464
private String name = "test_client";
465465
private String contact = "test_contact";
466466
private int siteId = 999;

src/test/java/com/uid2/admin/vertx/SharingServiceTest.java

Lines changed: 152 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,19 @@
2121
import org.junit.jupiter.params.provider.ValueSource;
2222

2323
import java.time.Instant;
24-
import java.util.HashMap;
25-
import java.util.HashSet;
26-
import java.util.Map;
27-
import java.util.Set;
24+
import java.util.*;
2825
import java.util.stream.Collectors;
2926
import java.util.stream.Stream;
3027

3128
import static org.junit.jupiter.api.Assertions.assertEquals;
3229
import static org.junit.jupiter.api.Assertions.assertNotNull;
33-
import static org.mockito.Mockito.doReturn;
34-
import static org.mockito.Mockito.verify;
30+
import static org.mockito.Mockito.*;
3531

3632
public class SharingServiceTest extends ServiceTestBase {
3733
@Override
3834
protected IService createService() {
3935
KeysetManager keysetManager = new KeysetManager(adminKeysetProvider, adminKeysetWriter, keysetKeyManager, true);
40-
return new SharingService(auth, writeLock, adminKeysetProvider, keysetManager, siteProvider, true);
36+
return new SharingService(auth, writeLock, adminKeysetProvider, keysetManager, siteProvider, true, clientKeyProvider);
4137
}
4238

4339
private void compareKeysetListToResult(AdminKeyset keyset, JsonArray actualList) {
@@ -1259,4 +1255,153 @@ void KeysetSetNewWithType(Vertx vertx, VertxTestContext testContext) {
12591255
testContext.completeNow();
12601256
});
12611257
}
1258+
1259+
@Test
1260+
void RelatedKeysetSetsWithClientTypes(Vertx vertx, VertxTestContext testContext) {
1261+
fakeAuth(Role.MAINTAINER);
1262+
1263+
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)));
1264+
AdminKeyset adminKeyset2 = new AdminKeyset(4, 7, "test", Set.of(12), Instant.now().getEpochSecond(),true, true, new HashSet<>(Arrays.asList(ClientType.DSP)));
1265+
AdminKeyset adminKeyset3 = new AdminKeyset(5, 4, "test", Set.of(5), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1266+
1267+
Map<Integer, AdminKeyset> keysets = new HashMap<Integer, AdminKeyset>() {{
1268+
put(3, adminKeyset1);
1269+
put(4, adminKeyset2);
1270+
put(5, adminKeyset3);
1271+
}};
1272+
1273+
setAdminKeysets(keysets);
1274+
mockSiteExistence(5, 7, 4, 8, 22, 25, 6);
1275+
doReturn(new Site(8, "test-name", true, new HashSet<>(Arrays.asList(ClientType.DSP)), null)).when(siteProvider).getSite(8);
1276+
1277+
1278+
get(vertx, testContext, "/api/sharing/keysets/related?site_id=8", response -> {
1279+
assertEquals(200, response.statusCode());
1280+
1281+
Set<Integer> expectedKeysetIds = new HashSet<>(Arrays.asList(adminKeyset1.getKeysetId(), adminKeyset2.getKeysetId()));
1282+
1283+
Set<Integer> actualKeysetIds = new HashSet<>();
1284+
JsonArray responseArray = response.bodyAsJsonArray();
1285+
for (int i = 0; i < responseArray.size(); i++) {
1286+
JsonObject item = responseArray.getJsonObject(i);
1287+
int keysetId = item.getInteger("keyset_id");
1288+
actualKeysetIds.add(keysetId);
1289+
}
1290+
assertEquals(true, actualKeysetIds.containsAll(expectedKeysetIds));
1291+
testContext.completeNow();
1292+
});
1293+
}
1294+
1295+
@Test
1296+
void RelatedKeysetSetsWithAllowedSites(Vertx vertx, VertxTestContext testContext) {
1297+
fakeAuth(Role.MAINTAINER);
1298+
1299+
AdminKeyset adminKeyset1 = new AdminKeyset(3, 1, "test", Set.of(4,8), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1300+
AdminKeyset adminKeyset2 = new AdminKeyset(4, 2, "test", Set.of(5,8), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1301+
AdminKeyset adminKeyset3 = new AdminKeyset(5, 3, "test", Set.of(6), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1302+
1303+
Map<Integer, AdminKeyset> keysets = new HashMap<Integer, AdminKeyset>() {{
1304+
put(3, adminKeyset1);
1305+
put(4, adminKeyset2);
1306+
put(5, adminKeyset3);
1307+
}};
1308+
1309+
setAdminKeysets(keysets);
1310+
mockSiteExistence(1,2,3,4,5,6,8);
1311+
doReturn(new Site(8, "test-name", true,null)).when(siteProvider).getSite(8);
1312+
1313+
1314+
get(vertx, testContext, "/api/sharing/keysets/related?site_id=8", response -> {
1315+
assertEquals(200, response.statusCode());
1316+
1317+
Set<Integer> expectedKeysetIds = new HashSet<>(Arrays.asList(adminKeyset1.getKeysetId(), adminKeyset2.getKeysetId()));
1318+
1319+
Set<Integer> actualKeysetIds = new HashSet<>();
1320+
JsonArray responseArray = response.bodyAsJsonArray();
1321+
for (int i = 0; i < responseArray.size(); i++) {
1322+
JsonObject item = responseArray.getJsonObject(i);
1323+
int keysetId = item.getInteger("keyset_id");
1324+
actualKeysetIds.add(keysetId);
1325+
}
1326+
assertEquals(true, actualKeysetIds.containsAll(expectedKeysetIds));
1327+
testContext.completeNow();
1328+
});
1329+
}
1330+
1331+
@Test
1332+
void RelatedKeysetSetsWithSameSiteId(Vertx vertx, VertxTestContext testContext) {
1333+
fakeAuth(Role.MAINTAINER);
1334+
1335+
AdminKeyset adminKeyset1 = new AdminKeyset(3, 1, "test", Set.of(4), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1336+
AdminKeyset adminKeyset2 = new AdminKeyset(4, 2, "test", Set.of(5), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1337+
AdminKeyset adminKeyset3 = new AdminKeyset(5, 8, "test", Set.of(6), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1338+
1339+
Map<Integer, AdminKeyset> keysets = new HashMap<Integer, AdminKeyset>() {{
1340+
put(3, adminKeyset1);
1341+
put(4, adminKeyset2);
1342+
put(5, adminKeyset3);
1343+
}};
1344+
1345+
setAdminKeysets(keysets);
1346+
mockSiteExistence(1,2,4,5,6,8);
1347+
doReturn(new Site(8, "test-name", true,null)).when(siteProvider).getSite(8);
1348+
1349+
1350+
get(vertx, testContext, "/api/sharing/keysets/related?site_id=8", response -> {
1351+
assertEquals(200, response.statusCode());
1352+
1353+
Set<Integer> expectedKeysetIds = new HashSet<>(Arrays.asList(adminKeyset3.getKeysetId()));
1354+
1355+
Set<Integer> actualKeysetIds = new HashSet<>();
1356+
JsonArray responseArray = response.bodyAsJsonArray();
1357+
for (int i = 0; i < responseArray.size(); i++) {
1358+
JsonObject item = responseArray.getJsonObject(i);
1359+
int keysetId = item.getInteger("keyset_id");
1360+
actualKeysetIds.add(keysetId);
1361+
}
1362+
assertEquals(true, actualKeysetIds.containsAll(expectedKeysetIds));
1363+
testContext.completeNow();
1364+
});
1365+
}
1366+
1367+
@Test
1368+
void RelatedKeysetSetsWithAllowSiteNull(Vertx vertx, VertxTestContext testContext) {
1369+
fakeAuth(Role.MAINTAINER);
1370+
1371+
AdminKeyset adminKeyset1 = new AdminKeyset(3, 1, "test", Set.of(4), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1372+
AdminKeyset adminKeyset2 = new AdminKeyset(4, 2, "test", Set.of(5), Instant.now().getEpochSecond(),true, true, new HashSet<>());
1373+
AdminKeyset adminKeyset3 = new AdminKeyset(5, 3, "test", null, Instant.now().getEpochSecond(),true, true, new HashSet<>());
1374+
1375+
Map<Integer, AdminKeyset> keysets = new HashMap<Integer, AdminKeyset>() {{
1376+
put(3, adminKeyset1);
1377+
put(4, adminKeyset2);
1378+
put(5, adminKeyset3);
1379+
}};
1380+
1381+
setAdminKeysets(keysets);
1382+
mockSiteExistence(1,2,3,4,5,8);
1383+
doReturn(new Site(8, "test-name", true,null)).when(siteProvider).getSite(8);
1384+
setClientKeys(
1385+
new ClientKeyServiceTest.LegacyClientBuilder()
1386+
.withRoles(new HashSet<>(Arrays.asList(Role.ID_READER)))
1387+
.withSiteId(8)
1388+
.build());
1389+
1390+
1391+
get(vertx, testContext, "/api/sharing/keysets/related?site_id=8", response -> {
1392+
assertEquals(200, response.statusCode());
1393+
1394+
Set<Integer> expectedKeysetIds = new HashSet<>(Arrays.asList(adminKeyset3.getKeysetId()));
1395+
1396+
Set<Integer> actualKeysetIds = new HashSet<>();
1397+
JsonArray responseArray = response.bodyAsJsonArray();
1398+
for (int i = 0; i < responseArray.size(); i++) {
1399+
JsonObject item = responseArray.getJsonObject(i);
1400+
int keysetId = item.getInteger("keyset_id");
1401+
actualKeysetIds.add(keysetId);
1402+
}
1403+
assertEquals(true, actualKeysetIds.containsAll(expectedKeysetIds));
1404+
testContext.completeNow();
1405+
});
1406+
}
12621407
}

webroot/adm/oncall/participant-summary.html

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@ <h5>Participant Opt-out Webhook</h5>
8282
<pre id="webhooksStandardOutput"></pre>
8383
</div>
8484
</div>
85+
<div class="row px-2">
86+
<div class="col section">
87+
<h5>Participant Related Keysets</h5>
88+
<div><b>A keyset is related to the participant if it matches below criteria:</b><br>
89+
a. Keysets where allowed_types include any of the clientTypes of the site<br>
90+
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>
91+
c. Keysets where allowed_sites include the leaked site<br>
92+
d. Keysets belonging to the leaked site itself<br></div>
93+
<pre class="errorDiv" id="relatedKeysetsErrorOutput"></pre>
94+
<pre id="relatedKeysetsStandardOutput"></pre>
95+
96+
<div class="col">
97+
<a href="#" class="btn btn-primary" id="doRotateKeysets">Rotate Keysets</a>
98+
<div>
99+
Normally, keys don't become active for 24 hours when rotated, which gives participants 24 hours before they need to call sdk.refresh().
100+
However in this case, rotation will make the new keys active immediately.
101+
This means participants will not be able to decrypt newly created UID tokens until they have called sdk.refresh().
102+
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>)
103+
</div>
104+
<div id="output">
105+
<pre id="rotateKeysetsErrorOutput"></pre>
106+
<pre id="rotateKeysetsStandardOutput"></pre>
107+
</div>
108+
</div>
109+
</div>
110+
</div>
85111
</div>
86112
</div>
87113

0 commit comments

Comments
 (0)