diff options
author | Valerij Fredriksen <freva@users.noreply.github.com> | 2021-01-21 08:17:24 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-01-21 08:17:24 +0100 |
commit | a38d2c9c684fa9639803bbc82a7b7129c335a7cb (patch) | |
tree | efe46c175bfda575b7c30611df7b8bc00b2e2161 /controller-server | |
parent | 3a456107e830d82ba5895f348016c84c9de5b727 (diff) | |
parent | 34d8f6b20233ec6203148690a5a099976481a6c3 (diff) |
Merge pull request #16120 from vespa-engine/freva/last-login
Store last login for tenants
Diffstat (limited to 'controller-server')
17 files changed, 350 insertions, 64 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/LockedTenant.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/LockedTenant.java index d27c585050b..e45bda0708e 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/LockedTenant.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/LockedTenant.java @@ -13,6 +13,7 @@ import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId; import com.yahoo.vespa.hosted.controller.api.integration.organization.Contact; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; @@ -33,10 +34,12 @@ public abstract class LockedTenant { final TenantName name; final Instant createdAt; + final LastLoginInfo lastLoginInfo; - private LockedTenant(TenantName name, Instant createdAt) { + private LockedTenant(TenantName name, Instant createdAt, LastLoginInfo lastLoginInfo) { this.name = requireNonNull(name); this.createdAt = requireNonNull(createdAt); + this.lastLoginInfo = requireNonNull(lastLoginInfo); } static LockedTenant of(Tenant tenant, Lock lock) { @@ -50,6 +53,8 @@ public abstract class LockedTenant { /** Returns a read-only copy of this */ public abstract Tenant get(); + public abstract LockedTenant with(LastLoginInfo lastLoginInfo); + @Override public String toString() { return "tenant '" + name + "'"; @@ -65,8 +70,8 @@ public abstract class LockedTenant { private final Optional<Contact> contact; private Athenz(TenantName name, AthenzDomain domain, Property property, Optional<PropertyId> propertyId, - Optional<Contact> contact, Instant createdAt) { - super(name, createdAt); + Optional<Contact> contact, Instant createdAt, LastLoginInfo lastLoginInfo) { + super(name, createdAt, lastLoginInfo); this.domain = domain; this.property = property; this.propertyId = propertyId; @@ -74,28 +79,33 @@ public abstract class LockedTenant { } private Athenz(AthenzTenant tenant) { - this(tenant.name(), tenant.domain(), tenant.property(), tenant.propertyId(), tenant.contact(), tenant.createdAt()); + this(tenant.name(), tenant.domain(), tenant.property(), tenant.propertyId(), tenant.contact(), tenant.createdAt(), tenant.lastLoginInfo()); } @Override public AthenzTenant get() { - return new AthenzTenant(name, domain, property, propertyId, contact, createdAt); + return new AthenzTenant(name, domain, property, propertyId, contact, createdAt, lastLoginInfo); } public Athenz with(AthenzDomain domain) { - return new Athenz(name, domain, property, propertyId, contact, createdAt); + return new Athenz(name, domain, property, propertyId, contact, createdAt, lastLoginInfo); } public Athenz with(Property property) { - return new Athenz(name, domain, property, propertyId, contact, createdAt); + return new Athenz(name, domain, property, propertyId, contact, createdAt, lastLoginInfo); } public Athenz with(PropertyId propertyId) { - return new Athenz(name, domain, property, Optional.of(propertyId), contact, createdAt); + return new Athenz(name, domain, property, Optional.of(propertyId), contact, createdAt, lastLoginInfo); } public Athenz with(Contact contact) { - return new Athenz(name, domain, property, propertyId, Optional.of(contact), createdAt); + return new Athenz(name, domain, property, propertyId, Optional.of(contact), createdAt, lastLoginInfo); + } + + @Override + public LockedTenant with(LastLoginInfo lastLoginInfo) { + return new Athenz(name, domain, property, propertyId, contact, createdAt, lastLoginInfo); } } @@ -108,20 +118,20 @@ public abstract class LockedTenant { private final BiMap<PublicKey, Principal> developerKeys; private final TenantInfo info; - private Cloud(TenantName name, Instant createdAt, Optional<Principal> creator, BiMap<PublicKey, Principal> developerKeys, TenantInfo info) { - super(name, createdAt); + private Cloud(TenantName name, Instant createdAt, LastLoginInfo lastLoginInfo, Optional<Principal> creator, BiMap<PublicKey, Principal> developerKeys, TenantInfo info) { + super(name, createdAt, lastLoginInfo); this.developerKeys = ImmutableBiMap.copyOf(developerKeys); this.creator = creator; this.info = info; } private Cloud(CloudTenant tenant) { - this(tenant.name(), tenant.createdAt(), Optional.empty(), tenant.developerKeys(), tenant.info()); + this(tenant.name(), tenant.createdAt(), tenant.lastLoginInfo(), Optional.empty(), tenant.developerKeys(), tenant.info()); } @Override public CloudTenant get() { - return new CloudTenant(name, createdAt, creator, developerKeys, info); + return new CloudTenant(name, createdAt, lastLoginInfo, creator, developerKeys, info); } public Cloud withDeveloperKey(PublicKey key, Principal principal) { @@ -129,17 +139,22 @@ public abstract class LockedTenant { if (keys.containsKey(key)) throw new IllegalArgumentException("Key " + KeyUtils.toPem(key) + " is already owned by " + keys.get(key)); keys.put(key, principal); - return new Cloud(name, createdAt, creator, keys, info); + return new Cloud(name, createdAt, lastLoginInfo, creator, keys, info); } public Cloud withoutDeveloperKey(PublicKey key) { BiMap<PublicKey, Principal> keys = HashBiMap.create(developerKeys); keys.remove(key); - return new Cloud(name, createdAt, creator, keys, info); + return new Cloud(name, createdAt, lastLoginInfo, creator, keys, info); } public Cloud withInfo(TenantInfo newInfo) { - return new Cloud(name, createdAt, creator, developerKeys, newInfo); + return new Cloud(name, createdAt, lastLoginInfo, creator, developerKeys, newInfo); + } + + @Override + public LockedTenant with(LastLoginInfo lastLoginInfo) { + return new Cloud(name, createdAt, lastLoginInfo, creator, developerKeys, info); } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java index ffb1aae7299..4c9cf4f105f 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/TenantController.java @@ -10,6 +10,7 @@ import com.yahoo.vespa.hosted.controller.persistence.CuratorDb; import com.yahoo.vespa.hosted.controller.security.AccessControl; import com.yahoo.vespa.hosted.controller.security.Credentials; import com.yahoo.vespa.hosted.controller.security.TenantSpec; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import java.time.Duration; @@ -121,6 +122,22 @@ public class TenantController { } } + /** + * Update last login times for the given tenant at the given user levers with the given instant, but only if the + * new instant is later + */ + public void updateLastLogin(TenantName tenantName, List<LastLoginInfo.UserLevel> userLevels, Instant loggedInAt) { + try (Lock lock = lock(tenantName)) { + Tenant tenant = require(tenantName); + LastLoginInfo loginInfo = tenant.lastLoginInfo(); + for (LastLoginInfo.UserLevel userLevel : userLevels) + loginInfo = loginInfo.withLastLoginIfLater(userLevel, loggedInAt); + + if (tenant.lastLoginInfo().equals(loginInfo)) return; // no change + curator.writeTenant(LockedTenant.of(tenant, lock).with(loginInfo).get()); + } + } + /** Deletes the given tenant. */ public void delete(TenantName tenant, Credentials credentials) { try (Lock lock = lock(tenant)) { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java index 79dbcc23299..3b5b01d16aa 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java @@ -18,6 +18,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.organization.Contact; import com.yahoo.vespa.hosted.controller.api.integration.organization.BillingInfo; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; import com.yahoo.vespa.hosted.controller.tenant.TenantInfoAddress; @@ -28,7 +29,9 @@ import java.security.Principal; import java.security.PublicKey; import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Supplier; @@ -66,6 +69,7 @@ public class TenantSerializer { private static final String productCodeField = "productCode"; private static final String pemDeveloperKeysField = "pemDeveloperKeys"; private static final String tenantInfoField = "info"; + private static final String lastLoginInfoField = "lastLoginInfo"; public Slime toSlime(Tenant tenant) { Slime slime = new Slime(); @@ -73,6 +77,7 @@ public class TenantSerializer { tenantObject.setString(nameField, tenant.name().value()); tenantObject.setString(typeField, valueOf(tenant.type())); tenantObject.setLong(createdAtField, tenant.createdAt().toEpochMilli()); + toSlime(tenant.lastLoginInfo(), tenantObject.setObject(lastLoginInfoField)); switch (tenant.type()) { case athenz: toSlime((AthenzTenant) tenant, tenantObject); break; @@ -116,6 +121,13 @@ public class TenantSerializer { billingInfoObject.setString(productCodeField, billingInfo.productCode()); } + private void toSlime(LastLoginInfo lastLoginInfo, Cursor lastLoginInfoObject) { + for (LastLoginInfo.UserLevel userLevel: LastLoginInfo.UserLevel.values()) { + lastLoginInfo.get(userLevel).ifPresent(lastLoginAt -> + lastLoginInfoObject.setLong(valueOf(userLevel), lastLoginAt.toEpochMilli())); + } + } + public Tenant tenantFrom(Slime slime, Supplier<Instant> tenantCreateTimeSupplier) { Inspector tenantObject = slime.get(); Tenant.Type type = typeOf(tenantObject.field(typeField).asString()); @@ -134,16 +146,18 @@ public class TenantSerializer { Optional<PropertyId> propertyId = SlimeUtils.optionalString(tenantObject.field(propertyIdField)).map(PropertyId::new); Optional<Contact> contact = contactFrom(tenantObject.field(contactField)); Instant createdAt = SlimeUtils.optionalLong(tenantObject.field(createdAtField)).map(Instant::ofEpochMilli).orElseGet(tenantCreateTimeSupplier); - return new AthenzTenant(name, domain, property, propertyId, contact, createdAt); + LastLoginInfo lastLoginInfo = lastLoginInfoFromSlime(tenantObject.field(lastLoginInfoField)); + return new AthenzTenant(name, domain, property, propertyId, contact, createdAt, lastLoginInfo); } private CloudTenant cloudTenantFrom(Inspector tenantObject, Supplier<Instant> tenantCreateTimeSupplier) { TenantName name = TenantName.from(tenantObject.field(nameField).asString()); Instant createdAt = SlimeUtils.optionalLong(tenantObject.field(createdAtField)).map(Instant::ofEpochMilli).orElseGet(tenantCreateTimeSupplier); + LastLoginInfo lastLoginInfo = lastLoginInfoFromSlime(tenantObject.field(lastLoginInfoField)); Optional<Principal> creator = SlimeUtils.optionalString(tenantObject.field(creatorField)).map(SimplePrincipal::new); BiMap<PublicKey, Principal> developerKeys = developerKeysFromSlime(tenantObject.field(pemDeveloperKeysField)); TenantInfo info = tenantInfoFromSlime(tenantObject.field(tenantInfoField)); - return new CloudTenant(name, createdAt, creator, developerKeys, info); + return new CloudTenant(name, createdAt, lastLoginInfo, creator, developerKeys, info); } private BiMap<PublicKey, Principal> developerKeysFromSlime(Inspector array) { @@ -186,6 +200,13 @@ public class TenantSerializer { .withAddress(tenantInfoAddressFromSlime(billingObject.field("address"))); } + private LastLoginInfo lastLoginInfoFromSlime(Inspector lastLoginInfoObject) { + Map<LastLoginInfo.UserLevel, Instant> lastLoginByUserLevel = new HashMap<>(); + lastLoginInfoObject.traverse((String name, Inspector value) -> + lastLoginByUserLevel.put(userLevelOf(name), Instant.ofEpochMilli(value.asLong()))); + return new LastLoginInfo(lastLoginByUserLevel); + } + void toSlime(TenantInfo info, Cursor parentCursor) { if (info.isEmpty()) return; Cursor infoCursor = parentCursor.setObject("info"); @@ -283,4 +304,22 @@ public class TenantSerializer { default: throw new IllegalArgumentException("Unexpected tenant type '" + type + "'."); } } + + private static LastLoginInfo.UserLevel userLevelOf(String value) { + switch (value) { + case "user": return LastLoginInfo.UserLevel.user; + case "developer": return LastLoginInfo.UserLevel.developer; + case "administrator": return LastLoginInfo.UserLevel.administrator; + default: throw new IllegalArgumentException("Unknown user level '" + value + "'."); + } + } + + private static String valueOf(LastLoginInfo.UserLevel userLevel) { + switch (userLevel) { + case user: return "user"; + case developer: return "developer"; + case administrator: return "administrator"; + default: throw new IllegalArgumentException("Unexpected user level '" + userLevel + "'."); + } + } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java index 13599664cef..5a1496bf507 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java @@ -91,6 +91,7 @@ import com.yahoo.vespa.hosted.controller.security.AccessControlRequests; import com.yahoo.vespa.hosted.controller.security.Credentials; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; import com.yahoo.vespa.hosted.controller.tenant.TenantInfoAddress; @@ -1999,6 +2000,13 @@ public class ApplicationApiHandler extends LoggingRequestHandler { object.setLong("createdAtMillis", tenant.createdAt().toEpochMilli()); lastDev.ifPresent(instant -> object.setLong("lastDeploymentToDevMillis", instant.toEpochMilli())); lastSubmission.ifPresent(instant -> object.setLong("lastSubmissionToProdMillis", instant.toEpochMilli())); + + tenant.lastLoginInfo().get(LastLoginInfo.UserLevel.user) + .ifPresent(instant -> object.setLong("lastLoginByUserMillis", instant.toEpochMilli())); + tenant.lastLoginInfo().get(LastLoginInfo.UserLevel.developer) + .ifPresent(instant -> object.setLong("lastLoginByDeveloperMillis", instant.toEpochMilli())); + tenant.lastLoginInfo().get(LastLoginInfo.UserLevel.administrator) + .ifPresent(instant -> object.setLong("lastLoginByAdministratorMillis", instant.toEpochMilli())); } /** Returns a copy of the given URI with the host and port from the given URI, the path set to the given path and the query set to given query*/ diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AthenzRoleFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AthenzRoleFilter.java index b9cf5ca4f4d..3ca7e5ac249 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AthenzRoleFilter.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AthenzRoleFilter.java @@ -1,11 +1,16 @@ // Copyright 2020 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.controller.restapi.filter; +import com.auth0.jwt.JWT; import com.google.inject.Inject; import com.yahoo.config.provision.ApplicationName; import com.yahoo.config.provision.TenantName; import com.yahoo.jdisc.http.filter.DiscFilterRequest; import com.yahoo.jdisc.http.filter.security.base.JsonSecurityRequestFilterBase; + +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.Date; import java.util.logging.Level; import com.yahoo.restapi.Path; import com.yahoo.vespa.athenz.api.AthenzDomain; @@ -64,9 +69,14 @@ public class AthenzRoleFilter extends JsonSecurityRequestFilterBase { try { Principal principal = request.getUserPrincipal(); if (principal instanceof AthenzPrincipal) { + Instant issuedAt = request.getClientCertificateChain().stream().findFirst() + .map(X509Certificate::getNotBefore) + .or(() -> Optional.ofNullable((String) request.getAttribute("okta.access-token")).map(iat -> JWT.decode(iat).getIssuedAt())) + .map(Date::toInstant) + .orElse(Instant.EPOCH); request.setAttribute(SecurityContext.ATTRIBUTE_NAME, new SecurityContext(principal, - roles((AthenzPrincipal) principal, - request.getUri()))); + roles((AthenzPrincipal) principal, request.getUri()), + issuedAt)); } } catch (Exception e) { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/LastLoginUpdateFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/LastLoginUpdateFilter.java new file mode 100644 index 00000000000..9b1ccc09499 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/LastLoginUpdateFilter.java @@ -0,0 +1,73 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.restapi.filter; + +import com.google.inject.Inject; +import com.yahoo.config.provision.TenantName; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.jdisc.http.filter.security.base.JsonSecurityRequestFilterBase; +import com.yahoo.vespa.hosted.controller.Controller; +import com.yahoo.vespa.hosted.controller.TenantController; +import com.yahoo.vespa.hosted.controller.api.role.Role; +import com.yahoo.vespa.hosted.controller.api.role.SecurityContext; +import com.yahoo.vespa.hosted.controller.api.role.TenantRole; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo.UserLevel.administrator; +import static com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo.UserLevel.developer; +import static com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo.UserLevel.user; + +/** + * A security filter protects all controller apis. + * + * @author freva + */ +public class LastLoginUpdateFilter extends JsonSecurityRequestFilterBase { + + private static final Logger log = Logger.getLogger(LastLoginUpdateFilter.class.getName()); + + private final TenantController tenantController; + + @Inject + public LastLoginUpdateFilter(Controller controller) { + this.tenantController = controller.tenants(); + } + + @Override + public Optional<ErrorResponse> filter(DiscFilterRequest request) { + try { + SecurityContext context = (SecurityContext) request.getAttribute(SecurityContext.ATTRIBUTE_NAME); + Map<TenantName, List<LastLoginInfo.UserLevel>> userLevelsByTenant = context.roles().stream() + .flatMap(LastLoginUpdateFilter::filterTenantUserLevels) + .collect(Collectors.groupingBy(Map.Entry::getKey, Collectors.mapping(Map.Entry::getValue, Collectors.toList()))); + + userLevelsByTenant.forEach((tenant, userLevels) -> tenantController.updateLastLogin(tenant, userLevels, context.issuedAt())); + } catch (Exception e) { + log.log(Level.WARNING, "Exception updating last login:", e); + } + return Optional.empty(); + } + + public static Stream<Map.Entry<TenantName, LastLoginInfo.UserLevel>> filterTenantUserLevels(Role role) { + if (!(role instanceof TenantRole)) + return Stream.empty(); + + TenantRole tenantRole = (TenantRole) role; + TenantName name = tenantRole.tenant(); + switch (tenantRole.definition()) { + case athenzTenantAdmin: + return Stream.of(Map.entry(name, user), Map.entry(name, developer), Map.entry(name, administrator)); + case reader: return Stream.of(Map.entry(name, user)); + case developer: return Stream.of(Map.entry(name, developer)); + case administrator: return Stream.of(Map.entry(name, administrator)); + default: return Stream.empty(); + } + } +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilter.java index 3be8d0cfe66..1c6511514a0 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilter.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilter.java @@ -86,14 +86,14 @@ public class SignatureFilter extends JsonSecurityRequestFilterBase { .map(CloudTenant.class::cast); if (tenant.isPresent() && tenant.get().developerKeys().containsKey(key)) return Optional.of(new SecurityContext(tenant.get().developerKeys().get(key), - Set.of(Role.reader(id.tenant()), - Role.developer(id.tenant())))); + Set.of(Role.reader(id.tenant()), Role.developer(id.tenant())), + controller.clock().instant())); Optional <Application> application = controller.applications().getApplication(TenantAndApplicationId.from(id)); if (application.isPresent() && application.get().deployKeys().contains(key)) return Optional.of(new SecurityContext(new SimplePrincipal("headless@" + id.tenant() + "." + id.application()), - Set.of(Role.reader(id.tenant()), - Role.headless(id.tenant(), id.application())))); + Set.of(Role.reader(id.tenant()), Role.headless(id.tenant(), id.application())), + controller.clock().instant())); } return Optional.empty(); } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/AthenzTenant.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/AthenzTenant.java index 7c0e78337ee..7fa46031c98 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/AthenzTenant.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/AthenzTenant.java @@ -27,8 +27,8 @@ public class AthenzTenant extends Tenant { * Use {@link #create(TenantName, AthenzDomain, Property, Optional, Instant)}. * */ public AthenzTenant(TenantName name, AthenzDomain domain, Property property, Optional<PropertyId> propertyId, - Optional<Contact> contact, Instant createdAt) { - super(name, createdAt, contact); + Optional<Contact> contact, Instant createdAt, LastLoginInfo lastLoginInfo) { + super(name, createdAt, lastLoginInfo, contact); this.domain = Objects.requireNonNull(domain, "domain must be non-null"); this.property = Objects.requireNonNull(property, "property must be non-null"); this.propertyId = Objects.requireNonNull(propertyId, "propertyId must be non-null"); @@ -62,7 +62,7 @@ public class AthenzTenant extends Tenant { /** Create a new Athenz tenant */ public static AthenzTenant create(TenantName name, AthenzDomain domain, Property property, Optional<PropertyId> propertyId, Instant createdAt) { - return new AthenzTenant(requireName(name), domain, property, propertyId, Optional.empty(), createdAt); + return new AthenzTenant(requireName(name), domain, property, propertyId, Optional.empty(), createdAt, LastLoginInfo.EMPTY); } @Override diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java index 2ed1ddc40e1..5d0bb780c81 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java @@ -23,8 +23,9 @@ public class CloudTenant extends Tenant { private final TenantInfo info; /** Public for the serialization layer — do not use! */ - public CloudTenant(TenantName name, Instant createdAt, Optional<Principal> creator, BiMap<PublicKey, Principal> developerKeys, TenantInfo info) { - super(name, createdAt, Optional.empty()); + public CloudTenant(TenantName name, Instant createdAt, LastLoginInfo lastLoginInfo, Optional<Principal> creator, + BiMap<PublicKey, Principal> developerKeys, TenantInfo info) { + super(name, createdAt, lastLoginInfo, Optional.empty()); this.creator = creator; this.developerKeys = developerKeys; this.info = Objects.requireNonNull(info); @@ -34,6 +35,7 @@ public class CloudTenant extends Tenant { public static CloudTenant create(TenantName tenantName, Instant createdAt, Principal creator) { return new CloudTenant(requireName(tenantName), createdAt, + LastLoginInfo.EMPTY, Optional.ofNullable(creator), ImmutableBiMap.of(), TenantInfo.EMPTY); } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/LastLoginInfo.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/LastLoginInfo.java new file mode 100644 index 00000000000..15f2f97e7d1 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/LastLoginInfo.java @@ -0,0 +1,55 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.tenant; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * @author freva + */ +public class LastLoginInfo { + + public static final LastLoginInfo EMPTY = new LastLoginInfo(Map.of()); + + private final Map<UserLevel, Instant> lastLoginByUserLevel; + + public LastLoginInfo(Map<UserLevel, Instant> lastLoginByUserLevel) { + this.lastLoginByUserLevel = Map.copyOf(lastLoginByUserLevel); + } + + public Optional<Instant> get(UserLevel userLevel) { + return Optional.ofNullable(lastLoginByUserLevel.get(userLevel)); + } + + /** + * Returns new instance with updated last login time if the given {@code loginAt} timestamp is after the current + * for the given {@code userLevel}, otherwise returns this + */ + public LastLoginInfo withLastLoginIfLater(UserLevel userLevel, Instant loginAt) { + Instant lastLogin = lastLoginByUserLevel.getOrDefault(userLevel, Instant.EPOCH); + if (loginAt.isAfter(lastLogin)) { + Map<UserLevel, Instant> lastLoginByUserLevel = new HashMap<>(this.lastLoginByUserLevel); + lastLoginByUserLevel.put(userLevel, loginAt); + return new LastLoginInfo(lastLoginByUserLevel); + } + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + LastLoginInfo lastLoginInfo = (LastLoginInfo) o; + return lastLoginByUserLevel.equals(lastLoginInfo.lastLoginByUserLevel); + } + + @Override + public int hashCode() { + return lastLoginByUserLevel.hashCode(); + } + + public enum UserLevel { user, developer, administrator }; +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Tenant.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Tenant.java index 7687796c697..f8b54e7eff3 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Tenant.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Tenant.java @@ -17,11 +17,13 @@ public abstract class Tenant { private final TenantName name; private final Instant createdAt; + private final LastLoginInfo lastLoginInfo; private final Optional<Contact> contact; - Tenant(TenantName name, Instant createdAt, Optional<Contact> contact) { + Tenant(TenantName name, Instant createdAt, LastLoginInfo lastLoginInfo, Optional<Contact> contact) { this.name = name; this.createdAt = createdAt; + this.lastLoginInfo = lastLoginInfo; this.contact = contact; } @@ -35,6 +37,11 @@ public abstract class Tenant { return createdAt; } + /** Returns login information for this tenant */ + public LastLoginInfo lastLoginInfo() { + return lastLoginInfo; + } + /** Contact information for this tenant */ public Optional<Contact> contact() { return contact; diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java index c26e4db7996..4fcf4f344e3 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java @@ -13,6 +13,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.organization.Contact; import com.yahoo.vespa.hosted.controller.api.role.SimplePrincipal; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; import com.yahoo.vespa.hosted.controller.tenant.TenantInfoAddress; import com.yahoo.vespa.hosted.controller.tenant.TenantInfoBillingContact; @@ -21,8 +22,9 @@ import org.junit.Test; import java.net.URI; import java.security.PublicKey; import java.time.Instant; -import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.junit.Assert.assertEquals; @@ -79,7 +81,8 @@ public class TenantSerializerTest { new Property("property1"), Optional.of(new PropertyId("1")), Optional.of(contact()), - Instant.EPOCH); + Instant.EPOCH, + lastLoginInfo(321L, 654L, 987L)); AthenzTenant serialized = (AthenzTenant) serializer.tenantFrom(serializer.toSlime(tenant), () -> { throw new UnsupportedOperationException(); }); assertEquals(tenant.contact(), serialized.contact()); } @@ -88,6 +91,7 @@ public class TenantSerializerTest { public void cloud_tenant() { CloudTenant tenant = new CloudTenant(TenantName.from("elderly-lady"), Instant.ofEpochMilli(1234L), + lastLoginInfo(123L, 456L, null), Optional.of(new SimplePrincipal("foobar-user")), ImmutableBiMap.of(publicKey, new SimplePrincipal("joe"), otherPublicKey, new SimplePrincipal("jane")), @@ -103,6 +107,7 @@ public class TenantSerializerTest { public void cloud_tenant_with_info() { CloudTenant tenant = new CloudTenant(TenantName.from("elderly-lady"), Instant.EPOCH, + lastLoginInfo(null, 789L, 654L), Optional.of(new SimplePrincipal("foobar-user")), ImmutableBiMap.of(publicKey, new SimplePrincipal("joe"), otherPublicKey, new SimplePrincipal("jane")), @@ -156,18 +161,25 @@ public class TenantSerializerTest { assertEquals(fullInfo, roundTripInfo); } - private Contact contact() { + private static Contact contact() { return new Contact( URI.create("http://contact1.test"), URI.create("http://property1.test"), URI.create("http://issue-tracker-1.test"), List.of( - Collections.singletonList("person1"), - Collections.singletonList("person2") + List.of("person1"), + List.of("person2") ), "queue", Optional.empty() ); } + private static LastLoginInfo lastLoginInfo(Long user, Long developer, Long administrator) { + Map<LastLoginInfo.UserLevel, Instant> lastLogins = new HashMap<>(); + Optional.ofNullable(user).map(Instant::ofEpochMilli).ifPresent(i -> lastLogins.put(LastLoginInfo.UserLevel.user, i)); + Optional.ofNullable(developer).map(Instant::ofEpochMilli).ifPresent(i -> lastLogins.put(LastLoginInfo.UserLevel.developer, i)); + Optional.ofNullable(administrator).map(Instant::ofEpochMilli).ifPresent(i -> lastLogins.put(LastLoginInfo.UserLevel.administrator, i)); + return new LastLoginInfo(lastLogins); + } } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java index 32f43f57152..434c83898ee 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java @@ -67,6 +67,7 @@ import com.yahoo.vespa.hosted.controller.routing.GlobalRouting; import com.yahoo.vespa.hosted.controller.security.AthenzCredentials; import com.yahoo.vespa.hosted.controller.security.AthenzTenantSpec; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.versions.VespaVersion; import com.yahoo.yolean.Exceptions; import org.junit.Before; @@ -140,7 +141,7 @@ public class ApplicationApiTest extends ControllerContainerTest { private static final UserId OTHER_USER_ID = new UserId("otheruser"); private static final UserId HOSTED_VESPA_OPERATOR = new UserId("johnoperator"); private static final OktaIdentityToken OKTA_IT = new OktaIdentityToken("okta-it"); - private static final OktaAccessToken OKTA_AT = new OktaAccessToken("okta-at"); + private static final OktaAccessToken OKTA_AT = new OktaAccessToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.he0ErCNloe4J7Id0Ry2SEDg09lKkZkfsRiGsdX_vgEg"); private ContainerTester tester; @@ -192,8 +193,10 @@ public class ApplicationApiTest extends ControllerContainerTest { new File("tenant-without-applications-with-id.json")); // GET a tenant with property ID and contact information updateContactInformation(); + tester.controller().tenants().updateLastLogin(TenantName.from("tenant2"), + List.of(LastLoginInfo.UserLevel.user, LastLoginInfo.UserLevel.administrator), Instant.ofEpochMilli(1234)); tester.assertResponse(request("/application/v4/tenant/tenant2", GET).userIdentity(USER_ID), - new File("tenant-with-contact-info.json")); + new File("tenant2.json")); // POST (create) an application tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/instance/instance1", POST) @@ -1237,7 +1240,7 @@ public class ApplicationApiTest extends ControllerContainerTest { // Create legacy tenant name containing underscores tester.controller().curator().writeTenant(new AthenzTenant(TenantName.from("my_tenant"), ATHENZ_TENANT_DOMAIN, - new Property("property1"), Optional.empty(), Optional.empty(), Instant.EPOCH)); + new Property("property1"), Optional.empty(), Optional.empty(), Instant.EPOCH, LastLoginInfo.EMPTY)); // POST (add) a Athenz tenant with dashes duplicates existing one with underscores tester.assertResponse(request("/application/v4/tenant/my-tenant", POST) diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-with-contact-info.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-with-contact-info.json deleted file mode 100644 index 921eae72161..00000000000 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant-with-contact-info.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "tenant": "tenant2", - "type": "ATHENS", - "athensDomain": "domain2", - "property": "property2", - "propertyId": "1234", - "propertyUrl": "www.properties.tld/1234", - "contactsUrl": "www.contacts.tld/1234", - "issueCreationUrl": "www.issues.tld/1234", - "contacts": [ - [ - "alice" - ], - [ - "bob" - ] - ], - "applications": [], - "metaData": { - "createdAtMillis": "(ignore)" - } -} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant2.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant2.json index 7da5918419c..497d80c96a5 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant2.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/tenant2.json @@ -17,6 +17,8 @@ ], "applications": [], "metaData": { - "createdAtMillis": "(ignore)" + "createdAtMillis": "(ignore)", + "lastLoginByUserMillis": 1234, + "lastLoginByAdministratorMillis": 1234 } }
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/LastLoginUpdateFilterTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/LastLoginUpdateFilterTest.java new file mode 100644 index 00000000000..df402e8c594 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/LastLoginUpdateFilterTest.java @@ -0,0 +1,59 @@ +package com.yahoo.vespa.hosted.controller.restapi.filter; + +import com.yahoo.application.container.handler.Request; +import com.yahoo.config.provision.TenantName; +import com.yahoo.container.jdisc.RequestHandlerTestDriver; +import com.yahoo.jdisc.http.HttpRequest; +import com.yahoo.jdisc.http.filter.DiscFilterRequest; +import com.yahoo.vespa.hosted.controller.ControllerTester; +import com.yahoo.vespa.hosted.controller.api.role.Role; +import com.yahoo.vespa.hosted.controller.api.role.SecurityContext; +import com.yahoo.vespa.hosted.controller.restapi.ApplicationRequestToDiscFilterRequestWrapper; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; +import org.junit.Test; + +import java.time.Instant; +import java.util.Set; + +import static com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo.UserLevel.administrator; +import static com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo.UserLevel.developer; +import static com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo.UserLevel.user; + +import static org.junit.Assert.assertEquals; + +public class LastLoginUpdateFilterTest { + + private static final TenantName tenant1 = TenantName.from("tenant1"); + private static final TenantName tenant2 = TenantName.from("tenant2"); + + private final ControllerTester tester = new ControllerTester(); + private final LastLoginUpdateFilter filter = new LastLoginUpdateFilter(tester.controller()); + + @Test + public void updateLastLoginTimeTest() { + tester.createTenant(tenant1.value()); + tester.createTenant(tenant2.value()); + + request(123, Role.developer(tenant1), Role.reader(tenant1), Role.athenzTenantAdmin(tenant2)); + assertLastLoginBy(tenant1, 123L, 123L, null); + assertLastLoginBy(tenant2, 123L, 123L, 123L); + + request(321, Role.administrator(tenant1), Role.reader(tenant1)); + assertLastLoginBy(tenant1, 321L, 123L, 321L); + assertLastLoginBy(tenant2, 123L, 123L, 123L); + } + + private void assertLastLoginBy(TenantName tenantName, Long lastUserLoginAt, Long lastDeveloperLoginAt, Long lastAdministratorLoginAt) { + LastLoginInfo loginInfo = tester.controller().tenants().require(tenantName).lastLoginInfo(); + assertEquals(lastUserLoginAt, loginInfo.get(user).map(Instant::toEpochMilli).orElse(null)); + assertEquals(lastDeveloperLoginAt, loginInfo.get(developer).map(Instant::toEpochMilli).orElse(null)); + assertEquals(lastAdministratorLoginAt, loginInfo.get(administrator).map(Instant::toEpochMilli).orElse(null)); + } + + private void request(long issuedAt, Role... roles) { + SecurityContext context = new SecurityContext(() -> "bob", Set.of(roles), Instant.ofEpochMilli(issuedAt)); + Request request = new Request("/", new byte[0], Request.Method.GET, context.principal()); + request.getAttributes().put(SecurityContext.ATTRIBUTE_NAME, context); + filter.filter(new ApplicationRequestToDiscFilterRequestWrapper(request)); + } +}
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java index d1f8fbebdb6..390823271b4 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java @@ -17,6 +17,7 @@ import com.yahoo.vespa.hosted.controller.api.role.SimplePrincipal; import com.yahoo.vespa.hosted.controller.application.TenantAndApplicationId; import com.yahoo.vespa.hosted.controller.restapi.ApplicationRequestToDiscFilterRequestWrapper; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; import org.junit.Before; import org.junit.Test; @@ -69,6 +70,7 @@ public class SignatureFilterTest { tester.curator().writeTenant(new CloudTenant(appId.tenant(), Instant.EPOCH, + LastLoginInfo.EMPTY, Optional.empty(), ImmutableBiMap.of(), TenantInfo.EMPTY)); @@ -98,25 +100,29 @@ public class SignatureFilterTest { verifySecurityContext(requestOf(signer.signed(request.copy(), Method.GET, InputStream::nullInputStream), emptyBody), new SecurityContext(new SimplePrincipal("headless@my-tenant.my-app"), Set.of(Role.reader(id.tenant()), - Role.headless(id.tenant(), id.application())))); + Role.headless(id.tenant(), id.application())), + tester.clock().instant())); // Signed POST request with X-Key header gets a headless role. byte[] hiBytes = new byte[]{0x48, 0x69}; verifySecurityContext(requestOf(signer.signed(request.copy(), Method.POST, () -> new ByteArrayInputStream(hiBytes)), hiBytes), new SecurityContext(new SimplePrincipal("headless@my-tenant.my-app"), Set.of(Role.reader(id.tenant()), - Role.headless(id.tenant(), id.application())))); + Role.headless(id.tenant(), id.application())), + tester.clock().instant())); // Signed request gets a developer role when a matching developer key is stored for the tenant. tester.curator().writeTenant(new CloudTenant(appId.tenant(), Instant.EPOCH, + LastLoginInfo.EMPTY, Optional.empty(), ImmutableBiMap.of(publicKey, () -> "user"), TenantInfo.EMPTY)); verifySecurityContext(requestOf(signer.signed(request.copy(), Method.POST, () -> new ByteArrayInputStream(hiBytes)), hiBytes), new SecurityContext(new SimplePrincipal("user"), Set.of(Role.reader(id.tenant()), - Role.developer(id.tenant())))); + Role.developer(id.tenant())), + tester.clock().instant())); // Unsigned requests still get no roles. verifySecurityContext(requestOf(request.copy().method("GET", HttpRequest.BodyPublishers.ofByteArray(emptyBody)).build(), emptyBody), |