summaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorValerij Fredriksen <valerijf@verizonmedia.com>2021-09-22 15:34:03 +0200
committerValerij Fredriksen <valerijf@verizonmedia.com>2021-09-22 15:35:20 +0200
commitf33a49faf68ea482d77536c3ff5a3af31a70fe94 (patch)
treef4f854fbe168e30c3a47223f4649bcff513fe47b /controller-server
parent916787e32fa8a99b8a13110e0cb730ecda195117 (diff)
Create serializer for user flags
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserFlagsSerializer.java86
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserFlagsSerializerTest.java133
2 files changed, 219 insertions, 0 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserFlagsSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserFlagsSerializer.java
new file mode 100644
index 00000000000..44d537883f9
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserFlagsSerializer.java
@@ -0,0 +1,86 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.user;
+
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.lang.MutableBoolean;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.vespa.flags.FetchVector;
+import com.yahoo.vespa.flags.FlagDefinition;
+import com.yahoo.vespa.flags.FlagId;
+import com.yahoo.vespa.flags.Flags;
+import com.yahoo.vespa.flags.RawFlag;
+import com.yahoo.vespa.flags.UnboundFlag;
+import com.yahoo.vespa.flags.json.Condition;
+import com.yahoo.vespa.flags.json.FlagData;
+import com.yahoo.vespa.flags.json.Rule;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * @author freva
+ */
+public class UserFlagsSerializer {
+ static void toSlime(Cursor cursor, Map<FlagId, FlagData> rawFlagData,
+ Set<TenantName> authorizedForTenantNames, boolean isOperator, String userEmail) {
+ FetchVector resolveVector = FetchVector.fromMap(Map.of(FetchVector.Dimension.CONSOLE_USER_EMAIL, userEmail));
+ List<FlagData> filteredFlagData = Flags.getAllFlags().stream()
+ // Only include flags that have CONSOLE_USER_EMAIL dimension, this should be replaced with more explicit
+ // 'target' annotation if/when that is added to flag definition
+ .filter(fd -> fd.getDimensions().contains(FetchVector.Dimension.CONSOLE_USER_EMAIL))
+ .map(FlagDefinition::getUnboundFlag)
+ .map(flag -> filteredFlagData(flag, Optional.ofNullable(rawFlagData.get(flag.id())), authorizedForTenantNames, isOperator, resolveVector))
+ .collect(Collectors.toUnmodifiableList());
+
+ byte[] bytes = FlagData.serializeListToUtf8Json(filteredFlagData);
+ SlimeUtils.copyObject(SlimeUtils.jsonToSlime(bytes).get(), cursor);
+ }
+
+ private static <T> FlagData filteredFlagData(UnboundFlag<T, ?, ?> definition, Optional<FlagData> original,
+ Set<TenantName> authorizedForTenantNames, boolean isOperator, FetchVector resolveVector) {
+ MutableBoolean encounteredEmpty = new MutableBoolean(false);
+ Optional<RawFlag> defaultValue = Optional.of(definition.serializer().serialize(definition.defaultValue()));
+ // Include the original rules from flag DB and the default value from code if there is no default rule in DB
+ List<Rule> rules = Stream.concat(original.stream().flatMap(fd -> fd.rules().stream()), Stream.of(new Rule(defaultValue)))
+ // Exclude rules that do not match the resolveVector
+ .filter(rule -> rule.partialMatch(resolveVector))
+ // Re-create each rule with value explicitly set, either from DB or default from code and
+ // a filtered set of conditions
+ .map(rule -> new Rule(rule.getValueToApply().or(() -> defaultValue),
+ rule.conditions().stream()
+ .flatMap(condition -> filteredCondition(condition, authorizedForTenantNames, isOperator, resolveVector).stream())
+ .collect(Collectors.toUnmodifiableList())))
+ // We can stop as soon as we hit the first rule that has no conditions
+ .takeWhile(rule -> !encounteredEmpty.getAndSet(rule.conditions().isEmpty()))
+ .collect(Collectors.toUnmodifiableList());
+
+ return new FlagData(definition.id(), new FetchVector(), rules);
+ }
+
+ private static Optional<Condition> filteredCondition(Condition condition, Set<TenantName> authorizedForTenantNames,
+ boolean isOperator, FetchVector resolveVector) {
+ // If the condition is one of the conditions that we resolve on the server, e.g. email, we do not need to
+ // propagate it back to the user
+ if (resolveVector.hasDimension(condition.dimension())) return Optional.empty();
+
+ // For the other dimensions, filter the values down to an allowed subset
+ switch (condition.dimension()) {
+ case TENANT_ID: return valueSubset(condition, tenant -> isOperator || authorizedForTenantNames.contains(TenantName.from(tenant)));
+ case APPLICATION_ID: return valueSubset(condition, appId -> isOperator || authorizedForTenantNames.stream().anyMatch(tenant -> appId.startsWith(tenant.value() + ":")));
+ default: throw new IllegalArgumentException("Dimension " + condition.dimension() + " is not supported for user flags");
+ }
+ }
+
+ private static Optional<Condition> valueSubset(Condition condition, Predicate<String> predicate) {
+ Condition.CreateParams createParams = condition.toCreateParams();
+ return Optional.of(createParams
+ .withValues(createParams.values().stream().filter(predicate).collect(Collectors.toUnmodifiableList()))
+ .createAs(condition.type()));
+ }
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserFlagsSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserFlagsSerializerTest.java
new file mode 100644
index 00000000000..8625628b74e
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserFlagsSerializerTest.java
@@ -0,0 +1,133 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.user;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.slime.Slime;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.test.json.JsonTestHelper;
+import com.yahoo.vespa.flags.FetchVector;
+import com.yahoo.vespa.flags.FlagId;
+import com.yahoo.vespa.flags.Flags;
+import com.yahoo.vespa.flags.JsonNodeRawFlag;
+import com.yahoo.vespa.flags.json.Condition;
+import com.yahoo.vespa.flags.json.FlagData;
+import com.yahoo.vespa.flags.json.Rule;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static com.yahoo.vespa.flags.FetchVector.Dimension.APPLICATION_ID;
+import static com.yahoo.vespa.flags.FetchVector.Dimension.CONSOLE_USER_EMAIL;
+import static com.yahoo.vespa.flags.FetchVector.Dimension.TENANT_ID;
+
+/**
+ * @author freva
+ */
+public class UserFlagsSerializerTest {
+
+ @Test
+ public void user_flag_test() throws IOException {
+ String email1 = "alice@domain.tld";
+ String email2 = "bob@domain.tld";
+
+ try (Flags.Replacer ignored = Flags.clearFlagsForTesting()) {
+ Flags.defineStringFlag("string-id", "default value", List.of("owner"), "1970-01-01", "2100-01-01", "desc", "mod", CONSOLE_USER_EMAIL);
+ Flags.defineIntFlag("int-id", 123, List.of("owner"), "1970-01-01", "2100-01-01", "desc", "mod", CONSOLE_USER_EMAIL, TENANT_ID, APPLICATION_ID);
+ Flags.defineDoubleFlag("double-id", 3.14d, List.of("owner"), "1970-01-01", "2100-01-01", "desc", "mod");
+ Flags.defineListFlag("list-id", List.of("a"), String.class, List.of("owner"), "1970-01-01", "2100-01-01", "desc", "mod", CONSOLE_USER_EMAIL);
+ Flags.defineJacksonFlag("jackson-id", new ExampleJacksonClass(123, "abc"), ExampleJacksonClass.class,
+ List.of("owner"), "1970-01-01", "2100-01-01", "desc", "mod", CONSOLE_USER_EMAIL, TENANT_ID);
+
+ Map<FlagId, FlagData> flagData = Stream.of(
+ flagData("string-id", rule("\"value1\"", condition(CONSOLE_USER_EMAIL, Condition.Type.WHITELIST, email1))),
+ flagData("int-id", rule("456")),
+ flagData("list-id",
+ rule("[\"value1\"]", condition(CONSOLE_USER_EMAIL, Condition.Type.WHITELIST, email1), condition(APPLICATION_ID, Condition.Type.BLACKLIST, "tenant1:video:default", "tenant1:video:default", "tenant2:music:default")),
+ rule("[\"value2\"]", condition(CONSOLE_USER_EMAIL, Condition.Type.WHITELIST, email2)),
+ rule("[\"value1\",\"value3\"]", condition(APPLICATION_ID, Condition.Type.BLACKLIST, "tenant1:video:default", "tenant1:video:default", "tenant2:music:default"))),
+ flagData("jackson-id", rule("{\"integer\":456,\"string\":\"xyz\"}", condition(CONSOLE_USER_EMAIL, Condition.Type.WHITELIST, email1), condition(TENANT_ID, Condition.Type.WHITELIST, "tenant1", "tenant3")))
+ ).collect(Collectors.toMap(FlagData::id, fd -> fd));
+
+ // double-id is not here as it does not have CONSOLE_USER_EMAIL dimension
+ assertUserFlags("{\"flags\":[" +
+ "{\"id\":\"int-id\",\"rules\":[{\"value\":456}]}," + // Default from DB
+ "{\"id\":\"jackson-id\",\"rules\":[{\"conditions\":[{\"type\":\"whitelist\",\"dimension\":\"tenant\"}],\"value\":{\"integer\":456,\"string\":\"xyz\"}},{\"value\":{\"integer\":123,\"string\":\"abc\"}}]}," + // Resolved for email
+ // Resolved for email, but conditions are empty since this user is not authorized for any tenants
+ "{\"id\":\"list-id\",\"rules\":[{\"conditions\":[{\"type\":\"blacklist\",\"dimension\":\"application\"}],\"value\":[\"value1\"]},{\"conditions\":[{\"type\":\"blacklist\",\"dimension\":\"application\"}],\"value\":[\"value1\",\"value3\"]},{\"value\":[\"a\"]}]}," +
+ "{\"id\":\"string-id\",\"rules\":[{\"value\":\"value1\"}]}]}", // resolved for email
+ flagData, Set.of(), false, email1);
+
+ // Same as the first one, but user is authorized for tenant1
+ assertUserFlags("{\"flags\":[" +
+ "{\"id\":\"int-id\",\"rules\":[{\"value\":456}]}," + // Default from DB
+ "{\"id\":\"jackson-id\",\"rules\":[{\"conditions\":[{\"type\":\"whitelist\",\"dimension\":\"tenant\",\"values\":[\"tenant1\"]}],\"value\":{\"integer\":456,\"string\":\"xyz\"}},{\"value\":{\"integer\":123,\"string\":\"abc\"}}]}," + // Resolved for email
+ // Resolved for email, but conditions have filtered out tenant2
+ "{\"id\":\"list-id\",\"rules\":[{\"conditions\":[{\"type\":\"blacklist\",\"dimension\":\"application\",\"values\":[\"tenant1:video:default\",\"tenant1:video:default\"]}],\"value\":[\"value1\"]},{\"conditions\":[{\"type\":\"blacklist\",\"dimension\":\"application\",\"values\":[\"tenant1:video:default\",\"tenant1:video:default\"]}],\"value\":[\"value1\",\"value3\"]},{\"value\":[\"a\"]}]}," +
+ "{\"id\":\"string-id\",\"rules\":[{\"value\":\"value1\"}]}]}", // resolved for email
+ flagData, Set.of("tenant1"), false, email1);
+
+ // As operator no conditions are filtered, but the email precondition is applied
+ assertUserFlags("{\"flags\":[" +
+ "{\"id\":\"int-id\",\"rules\":[{\"value\":456}]}," + // Default from DB
+ "{\"id\":\"jackson-id\",\"rules\":[{\"value\":{\"integer\":123,\"string\":\"abc\"}}]}," + // Default from code, no DB values match
+ // Includes last value from DB which is not conditioned on email and the default from code
+ "{\"id\":\"list-id\",\"rules\":[{\"conditions\":[{\"type\":\"blacklist\",\"dimension\":\"application\",\"values\":[\"tenant1:video:default\",\"tenant1:video:default\",\"tenant2:music:default\"]}],\"value\":[\"value1\",\"value3\"]},{\"value\":[\"a\"]}]}," +
+ "{\"id\":\"string-id\",\"rules\":[{\"value\":\"default value\"}]}]}", // Default from code
+ flagData, Set.of(), true, "operator@domain.tld");
+ }
+ }
+
+ private static FlagData flagData(String id, Rule... rules) {
+ return new FlagData(new FlagId(id), new FetchVector(), rules);
+ }
+
+ private static Rule rule(String data, Condition... conditions) {
+ return new Rule(Optional.ofNullable(data).map(JsonNodeRawFlag::fromJson), conditions);
+ }
+
+ private static Condition condition(FetchVector.Dimension dimension, Condition.Type type, String... values) {
+ return new Condition.CreateParams(dimension).withValues(values).createAs(type);
+ }
+
+ private static void assertUserFlags(String expected, Map<FlagId, FlagData> rawFlagData,
+ Set<String> authorizedForTenantNames, boolean isOperator, String userEmail) throws IOException {
+ Slime slime = new Slime();
+ UserFlagsSerializer.toSlime(slime.setObject(), rawFlagData, authorizedForTenantNames.stream().map(TenantName::from).collect(Collectors.toSet()), isOperator, userEmail);
+ JsonTestHelper.assertJsonEquals(expected,
+ new String(SlimeUtils.toJsonBytes(slime), StandardCharsets.UTF_8));
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private static class ExampleJacksonClass {
+ @JsonProperty("integer") public final int integer;
+ @JsonProperty("string") public final String string;
+ private ExampleJacksonClass(@JsonProperty("integer") int integer, @JsonProperty("string") String string) {
+ this.integer = integer;
+ this.string = string;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ ExampleJacksonClass that = (ExampleJacksonClass) o;
+ return integer == that.integer &&
+ Objects.equals(string, that.string);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(integer, string);
+ }
+ }
+} \ No newline at end of file