diff options
author | Valerij Fredriksen <freva@users.noreply.github.com> | 2021-09-22 19:24:50 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-09-22 19:24:50 +0200 |
commit | f024e11bd602335db7a22492e4d54841f7fea82f (patch) | |
tree | 6ca583381e510bc8b6bc63a8ac227fb7044b072d /controller-server | |
parent | a781b8c32eb6b470bdc64c281de9ba29987a1711 (diff) | |
parent | 01a79eb963ea6c65efa3ba21807d3ced2f79defc (diff) |
Merge pull request #19249 from vespa-engine/freva/user-flagv7.472.1
Expose user feature flags in /user/v1/user response
Diffstat (limited to 'controller-server')
10 files changed, 327 insertions, 91 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java index 044b7b76d1e..157f57b3bea 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java @@ -19,6 +19,7 @@ import com.yahoo.slime.Slime; import com.yahoo.slime.SlimeStream; import com.yahoo.slime.SlimeUtils; import com.yahoo.text.Text; +import com.yahoo.vespa.configserver.flags.FlagsDb; import com.yahoo.vespa.flags.BooleanFlag; import com.yahoo.vespa.flags.FetchVector; import com.yahoo.vespa.flags.FlagSource; @@ -69,15 +70,17 @@ public class UserApiHandler extends LoggingRequestHandler { private final UserManagement users; private final Controller controller; + private final FlagsDb flagsDb; private final BooleanFlag enable_public_signup_flow; private final IntFlag maxTrialTenants; private final BooleanFlag enabledHorizonDashboard; @Inject - public UserApiHandler(Context parentCtx, UserManagement users, Controller controller, FlagSource flagSource) { + public UserApiHandler(Context parentCtx, UserManagement users, Controller controller, FlagSource flagSource, FlagsDb flagsDb) { super(parentCtx); this.users = users; this.controller = controller; + this.flagsDb = flagsDb; this.enable_public_signup_flow = PermanentFlags.ENABLE_PUBLIC_SIGNUP_FLOW.bindTo(flagSource); this.maxTrialTenants = PermanentFlags.MAX_TRIAL_TENANTS.bindTo(flagSource); this.enabledHorizonDashboard = Flags.ENABLED_HORIZON_DASHBOARD.bindTo(flagSource); @@ -170,6 +173,7 @@ public class UserApiHandler extends LoggingRequestHandler { root.setBool("isPublic", controller.system().isPublic()); root.setBool("isCd", controller.system().isCd()); + // TODO (freva): Remove after users have migrated to use 'flags' root.setBool(enable_public_signup_flow.id().toString(), enable_public_signup_flow.with(FetchVector.Dimension.CONSOLE_USER_EMAIL, user.email()).value()); root.setBool("hasTrialCapacity", hasTrialCapacity()); @@ -197,6 +201,8 @@ public class UserApiHandler extends LoggingRequestHandler { operatorRoles.forEach(role -> operator.addString(role.definition().name())); } + UserFlagsSerializer.toSlime(root, flagsDb.getAllFlagData(), tenantRolesByTenantName.keySet(), !operatorRoles.isEmpty(), user.email()); + return new SlimeJsonResponse(slime); } @@ -249,7 +255,7 @@ public class UserApiHandler extends LoggingRequestHandler { }); } - private void toSlime(Cursor userObject, User user) { + private static void toSlime(Cursor userObject, User user) { if (user.name() != null) userObject.setString("name", user.name()); userObject.setString("email", user.email()); if (user.nickname() != null) userObject.setString("nickname", user.nickname()); @@ -376,7 +382,7 @@ public class UserApiHandler extends LoggingRequestHandler { return Exceptions.uncheck(() -> SlimeUtils.jsonToSlime(IOUtils.readBytes(request.getData(), 1 << 10)).get()); } - private <Type> Type require(String name, Function<Inspector, Type> mapper, Inspector object) { + private static <Type> Type require(String name, Function<Inspector, Type> mapper, Inspector object) { if ( ! object.field(name).valid()) throw new IllegalArgumentException("Missing field '" + name + "'."); return mapper.apply(object.field(name)); } 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/ContainerTester.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ContainerTester.java index 10f143a8e96..1d844859c37 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ContainerTester.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ContainerTester.java @@ -26,7 +26,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Optional; -import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Pattern; diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiOnPremTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiOnPremTest.java index acd481030e2..c884eae8afc 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiOnPremTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiOnPremTest.java @@ -5,6 +5,8 @@ import com.yahoo.application.container.handler.Request; import com.yahoo.vespa.athenz.api.AthenzDomain; import com.yahoo.vespa.athenz.api.AthenzIdentity; import com.yahoo.vespa.athenz.utils.AthenzIdentities; +import com.yahoo.vespa.flags.Flags; +import com.yahoo.vespa.flags.PermanentFlags; import com.yahoo.vespa.hosted.controller.ControllerTester; import com.yahoo.vespa.hosted.controller.api.integration.user.User; import com.yahoo.vespa.hosted.controller.restapi.ContainerTester; @@ -25,40 +27,42 @@ public class UserApiOnPremTest extends ControllerContainerTest { @Test public void userMetadataOnPremTest() { - ContainerTester tester = new ContainerTester(container, responseFiles); - ControllerTester controller = new ControllerTester(tester); - User user = new User("dev@domail", "Joe Developer", "dev", null); + try (Flags.Replacer ignored = Flags.clearFlagsForTesting(PermanentFlags.MAX_TRIAL_TENANTS.id(), PermanentFlags.ENABLE_PUBLIC_SIGNUP_FLOW.id())) { + ContainerTester tester = new ContainerTester(container, responseFiles); + ControllerTester controller = new ControllerTester(tester); + User user = new User("dev@domail", "Joe Developer", "dev", null); - controller.createTenant("tenant1", "domain1", 1L); - controller.createApplication("tenant1", "app1", "default"); - controller.createApplication("tenant1", "app2", "default"); - controller.createApplication("tenant1", "app2", "myinstance"); - controller.createApplication("tenant1", "app3"); + controller.createTenant("tenant1", "domain1", 1L); + controller.createApplication("tenant1", "app1", "default"); + controller.createApplication("tenant1", "app2", "default"); + controller.createApplication("tenant1", "app2", "myinstance"); + controller.createApplication("tenant1", "app3"); - controller.createTenant("tenant2", "domain2", 2L); - controller.createApplication("tenant2", "app2", "test"); + controller.createTenant("tenant2", "domain2", 2L); + controller.createApplication("tenant2", "app2", "test"); - controller.createTenant("tenant3", "domain3", 3L); - controller.createApplication("tenant3", "app1"); + controller.createTenant("tenant3", "domain3", 3L); + controller.createApplication("tenant3", "app1"); - controller.createTenant("sandbox", "domain4", 4L); - controller.createApplication("sandbox", "app1", "default"); - controller.createApplication("sandbox", "app2", "default"); - controller.createApplication("sandbox", "app2", "dev"); + controller.createTenant("sandbox", "domain4", 4L); + controller.createApplication("sandbox", "app1", "default"); + controller.createApplication("sandbox", "app2", "default"); + controller.createApplication("sandbox", "app2", "dev"); - AthenzIdentity operator = AthenzIdentities.from("vespa.alice"); - controller.athenzDb().addHostedOperator(operator); - AthenzIdentity tenantAdmin = AthenzIdentities.from("domain1.bob"); - Stream.of("domain1", "domain2", "domain4") - .map(AthenzDomain::new) - .map(controller.athenzDb()::getOrCreateDomain) - .forEach(d -> d.admin(AthenzIdentities.from("domain1.bob"))); + AthenzIdentity operator = AthenzIdentities.from("vespa.alice"); + controller.athenzDb().addHostedOperator(operator); + AthenzIdentity tenantAdmin = AthenzIdentities.from("domain1.bob"); + Stream.of("domain1", "domain2", "domain4") + .map(AthenzDomain::new) + .map(controller.athenzDb()::getOrCreateDomain) + .forEach(d -> d.admin(AthenzIdentities.from("domain1.bob"))); - tester.assertResponse(createUserRequest(user, operator), - new File("user-without-applications.json")); + tester.assertResponse(createUserRequest(user, operator), + new File("user-without-applications.json")); - tester.assertResponse(createUserRequest(user, tenantAdmin), - new File("user-with-applications-athenz.json")); + tester.assertResponse(createUserRequest(user, tenantAdmin), + new File("user-with-applications-athenz.json")); + } } private Request createUserRequest(User user, AthenzIdentity identity) { diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java index 03f1d75a50b..9198369a3ad 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java @@ -4,6 +4,7 @@ package com.yahoo.vespa.hosted.controller.restapi.user; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.SystemName; import com.yahoo.config.provision.TenantName; +import com.yahoo.vespa.flags.Flags; import com.yahoo.vespa.flags.InMemoryFlagSource; import com.yahoo.vespa.flags.PermanentFlags; import com.yahoo.vespa.hosted.controller.ControllerTester; @@ -205,65 +206,68 @@ public class UserApiTest extends ControllerContainerCloudTest { @Test public void userMetadataTest() { - ContainerTester tester = new ContainerTester(container, responseFiles); - ((InMemoryFlagSource) tester.controller().flagSource()) - .withBooleanFlag(PermanentFlags.ENABLE_PUBLIC_SIGNUP_FLOW.id(), true); - ControllerTester controller = new ControllerTester(tester); - Set<Role> operator = Set.of(Role.hostedOperator(), Role.hostedSupporter(), Role.hostedAccountant()); - User user = new User("dev@domail", "Joe Developer", "dev", null); - - tester.assertResponse(request("/user/v1/user") - .roles(operator) - .user(user), - new File("user-without-applications.json")); - - controller.createTenant("tenant1", Tenant.Type.cloud); - controller.createApplication("tenant1", "app1", "default"); - controller.createApplication("tenant1", "app2", "default"); - controller.createApplication("tenant1", "app2", "myinstance"); - controller.createApplication("tenant1", "app3"); - - controller.createTenant("tenant2", Tenant.Type.cloud); - controller.createApplication("tenant2", "app2", "test"); - - controller.createTenant("tenant3", Tenant.Type.cloud); - controller.createApplication("tenant3", "app1"); - - controller.createTenant("sandbox", Tenant.Type.cloud); - controller.createApplication("sandbox", "app1", "default"); - controller.createApplication("sandbox", "app2", "default"); - controller.createApplication("sandbox", "app2", "dev"); - - // Should still be empty because none of the roles explicitly refer to any of the applications - tester.assertResponse(request("/user/v1/user") - .roles(operator) - .user(user), - new File("user-without-applications.json")); - - // Empty applications because tenant dummy does not exist - tester.assertResponse(request("/user/v1/user") - .roles(Set.of(Role.administrator(TenantName.from("tenant1")), - Role.developer(TenantName.from("tenant2")), - Role.developer(TenantName.from("sandbox")), - Role.reader(TenantName.from("sandbox")))) - .user(user), - new File("user-with-applications-cloud.json")); + try (Flags.Replacer ignored = Flags.clearFlagsForTesting(PermanentFlags.MAX_TRIAL_TENANTS.id(), PermanentFlags.ENABLE_PUBLIC_SIGNUP_FLOW.id())) { + ContainerTester tester = new ContainerTester(container, responseFiles); + ((InMemoryFlagSource) tester.controller().flagSource()) + .withBooleanFlag(PermanentFlags.ENABLE_PUBLIC_SIGNUP_FLOW.id(), true); + ControllerTester controller = new ControllerTester(tester); + Set<Role> operator = Set.of(Role.hostedOperator(), Role.hostedSupporter(), Role.hostedAccountant()); + User user = new User("dev@domail", "Joe Developer", "dev", null); + + tester.assertResponse(request("/user/v1/user") + .roles(operator) + .user(user), + new File("user-without-applications.json")); + + controller.createTenant("tenant1", Tenant.Type.cloud); + controller.createApplication("tenant1", "app1", "default"); + controller.createApplication("tenant1", "app2", "default"); + controller.createApplication("tenant1", "app2", "myinstance"); + controller.createApplication("tenant1", "app3"); + + controller.createTenant("tenant2", Tenant.Type.cloud); + controller.createApplication("tenant2", "app2", "test"); + + controller.createTenant("tenant3", Tenant.Type.cloud); + controller.createApplication("tenant3", "app1"); + + controller.createTenant("sandbox", Tenant.Type.cloud); + controller.createApplication("sandbox", "app1", "default"); + controller.createApplication("sandbox", "app2", "default"); + controller.createApplication("sandbox", "app2", "dev"); + + // Should still be empty because none of the roles explicitly refer to any of the applications + tester.assertResponse(request("/user/v1/user") + .roles(operator) + .user(user), + new File("user-without-applications.json")); + + // Empty applications because tenant dummy does not exist + tester.assertResponse(request("/user/v1/user") + .roles(Set.of(Role.administrator(TenantName.from("tenant1")), + Role.developer(TenantName.from("tenant2")), + Role.developer(TenantName.from("sandbox")), + Role.reader(TenantName.from("sandbox")))) + .user(user), + new File("user-with-applications-cloud.json")); + } } @Test public void maxTrialTenants() { - ContainerTester tester = new ContainerTester(container, responseFiles); - ((InMemoryFlagSource) tester.controller().flagSource()) - .withIntFlag(PermanentFlags.MAX_TRIAL_TENANTS.id(), 1) - .withBooleanFlag(PermanentFlags.ENABLE_PUBLIC_SIGNUP_FLOW.id(), true); - ControllerTester controller = new ControllerTester(tester); - Set<Role> operator = Set.of(Role.hostedOperator(), Role.hostedSupporter(), Role.hostedAccountant()); - User user = new User("dev@domail", "Joe Developer", "dev", null); - - controller.createTenant("tenant1", Tenant.Type.cloud); - - tester.assertResponse( - request("/user/v1/user").user(user), - new File("user-without-trial-capacity-cloud.json")); + try (Flags.Replacer ignored = Flags.clearFlagsForTesting(PermanentFlags.MAX_TRIAL_TENANTS.id(), PermanentFlags.ENABLE_PUBLIC_SIGNUP_FLOW.id())) { + ContainerTester tester = new ContainerTester(container, responseFiles); + ((InMemoryFlagSource) tester.controller().flagSource()) + .withIntFlag(PermanentFlags.MAX_TRIAL_TENANTS.id(), 1) + .withBooleanFlag(PermanentFlags.ENABLE_PUBLIC_SIGNUP_FLOW.id(), true); + ControllerTester controller = new ControllerTester(tester); + User user = new User("dev@domail", "Joe Developer", "dev", null); + + controller.createTenant("tenant1", Tenant.Type.cloud); + + tester.assertResponse( + request("/user/v1/user").user(user), + new File("user-without-trial-capacity-cloud.json")); + } } } 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 diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-with-applications-athenz.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-with-applications-athenz.json index 5d3a38334ad..0a416600b2c 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-with-applications-athenz.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-with-applications-athenz.json @@ -31,5 +31,6 @@ "reader" ] } - } + }, + "flags": [{"id":"enable-public-signup-flow","rules":[{"value":false}]}] } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-with-applications-cloud.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-with-applications-cloud.json index e883993cb53..4e179ad83c5 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-with-applications-cloud.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-with-applications-cloud.json @@ -29,5 +29,6 @@ ], "enabled-horizon-dashboard":false } - } + }, + "flags": [{"id":"enable-public-signup-flow","rules":[{"value":false}]}] } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-without-applications.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-without-applications.json index 3bf999b490b..7eb445140e7 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-without-applications.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-without-applications.json @@ -14,5 +14,6 @@ "hostedOperator", "hostedSupporter", "hostedAccountant" - ] + ], + "flags": [{"id":"enable-public-signup-flow","rules":[{"value":false}]}] } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-without-trial-capacity-cloud.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-without-trial-capacity-cloud.json index 27242424579..3c1edab8cfc 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-without-trial-capacity-cloud.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/user-without-trial-capacity-cloud.json @@ -9,5 +9,6 @@ "nickname": "dev", "verified":false }, - "tenants": {} + "tenants": {}, + "flags": [{"id":"enable-public-signup-flow","rules":[{"value":false}]}] }
\ No newline at end of file |