From 0b0d2c034b11ccad66390e781fa3f743e3ca36ff Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 30 Jun 2026 09:49:44 -0700 Subject: [PATCH 01/15] Role-restricted API keys --- .../labkey/api/security/ApiKeyManager.java | 9 ++++- .../api/security/AuthenticationProvider.java | 19 ++++++++++ .../org/labkey/api/security/ClonedUser.java | 2 +- .../org/labkey/api/security/ElevatedUser.java | 21 +++++++++- .../org/labkey/api/security/LimitedUser.java | 2 +- .../api/security/RoleRestrictedUser.java | 38 +++++++++++++++++++ api/src/org/labkey/api/security/RoleSet.java | 2 +- .../labkey/api/security/SecurityManager.java | 16 ++++++-- api/src/org/labkey/api/security/User.java | 4 +- .../security/WrappedPermissionsContext.java | 17 ++------- .../GroupImpersonationContextFactory.java | 3 +- .../ImpersonationContextFactory.java | 7 ++-- .../RoleImpersonationContextFactory.java | 3 +- .../UserImpersonationContextFactory.java | 3 +- .../login/DbLoginAuthenticationProvider.java | 7 +++- 15 files changed, 116 insertions(+), 37 deletions(-) create mode 100644 api/src/org/labkey/api/security/RoleRestrictedUser.java diff --git a/api/src/org/labkey/api/security/ApiKeyManager.java b/api/src/org/labkey/api/security/ApiKeyManager.java index 69684001985..70998d8b24d 100644 --- a/api/src/org/labkey/api/security/ApiKeyManager.java +++ b/api/src/org/labkey/api/security/ApiKeyManager.java @@ -39,6 +39,7 @@ import org.labkey.api.query.FieldKey; import org.labkey.api.security.UserManager.SessionHandler; import org.labkey.api.security.ValidEmail.InvalidEmailException; +import org.labkey.api.security.roles.Role; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.LenientStartupPropertyHandler; import org.labkey.api.settings.StartupProperty; @@ -183,13 +184,17 @@ public void updateLastUsed(String apikey) try (Transaction t = scope.beginTransaction(TRANSACTION_KIND)) { - SQLFragment sql = new SQLFragment("UPDATE " + CoreSchema.getInstance().getTableAPIKeys() + " SET LastUsed = ? WHERE Crypt = ?", new Date(), crypt(apikey)); + SQLFragment sql = new SQLFragment("UPDATE ") + .append(CoreSchema.getInstance().getTableAPIKeys()) + .append(" SET LastUsed = ? WHERE Crypt = ?") + .add(new Date()) + .add(crypt(apikey)); new SqlExecutor(scope).execute(sql); t.commit(); } } - public record ApiKeyAuthentication(int createdBy, int rowId) + public record ApiKeyAuthentication(int createdBy, int rowId, @Nullable Class roleRestrictionClass) { public User getUser() { diff --git a/api/src/org/labkey/api/security/AuthenticationProvider.java b/api/src/org/labkey/api/security/AuthenticationProvider.java index b293a5ddf02..b4c6c20a4ac 100644 --- a/api/src/org/labkey/api/security/AuthenticationProvider.java +++ b/api/src/org/labkey/api/security/AuthenticationProvider.java @@ -30,6 +30,7 @@ import org.labkey.api.security.AuthenticationManager.AuthenticationStatus; import org.labkey.api.security.AuthenticationManager.AuthenticationValidator; import org.labkey.api.security.ValidEmail.InvalidEmailException; +import org.labkey.api.security.roles.Role; import org.labkey.api.settings.StandardStartupPropertyHandler; import org.labkey.api.settings.StartupProperty; import org.labkey.api.settings.StartupPropertyEntry; @@ -306,6 +307,7 @@ class AuthenticationResponse private @Nullable ActionURL _redirectURL = null; private @NotNull Map _userAttributeMap = Collections.emptyMap(); // A case-insensitive map of attribute names and values associated with the user private @NotNull Map _authenticationProperties = Collections.emptyMap(); + private @Nullable Class _roleRestriction; // Limit user's permissions to those of this role private boolean _requireSecondary = true; // Require secondary authentication private boolean _reauth = false; private @Nullable String _successDetails = null; // An optional string describing how successful authentication took place, which will @@ -427,6 +429,23 @@ public AuthenticationResponse setAuthenticationProperties(Map ma return this; } + /** + * Get the role restriction class, if present + */ + public @Nullable Class getRoleRestriction() + { + return _roleRestriction; + } + + /** + * Set a role that restricts this user's permissions + */ + public AuthenticationResponse setRoleRestriction(Class clazz) + { + _roleRestriction = clazz; + return this; + } + public @Nullable String getSuccessDetails() { return _successDetails; diff --git a/api/src/org/labkey/api/security/ClonedUser.java b/api/src/org/labkey/api/security/ClonedUser.java index b5870e4bf36..591d3511db3 100644 --- a/api/src/org/labkey/api/security/ClonedUser.java +++ b/api/src/org/labkey/api/security/ClonedUser.java @@ -45,7 +45,7 @@ protected ClonedUser(String email, int userId, String displayName, String firstN setPhone(phone); setLastActivity(lastActivity); - setImpersonationContext(ctx); + setPermissionsContext(ctx); } // Map a stream of role classes to a set of roles diff --git a/api/src/org/labkey/api/security/ElevatedUser.java b/api/src/org/labkey/api/security/ElevatedUser.java index c65bd223d75..88915061029 100644 --- a/api/src/org/labkey/api/security/ElevatedUser.java +++ b/api/src/org/labkey/api/security/ElevatedUser.java @@ -15,6 +15,7 @@ */ package org.labkey.api.security; +import com.google.common.collect.Streams; import org.labkey.api.audit.permissions.CanSeeAuditLogPermission; import org.labkey.api.data.Container; import org.labkey.api.security.permissions.Permission; @@ -26,6 +27,7 @@ import java.util.Collection; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * A wrapped user that possesses all the security properties (groups, roles, impersonation status, etc.) of the @@ -35,9 +37,26 @@ */ public class ElevatedUser extends ClonedUser { + private static class ElevatedUserContext extends WrappedPermissionsContext + { + private final Set _additionalRoles; + + private ElevatedUserContext(PermissionsContext delegate, Set additionalRoles) + { + super(delegate); + _additionalRoles = additionalRoles; + } + + @Override + public Stream getAssignedRoles(User user, SecurableResource resource) + { + return Streams.concat(_additionalRoles.stream(), super.getAssignedRoles(user, resource)); + } + } + private ElevatedUser(User user, Set rolesToAdd) { - super(user, new WrappedPermissionsContext(user.getPermissionsContext(), rolesToAdd)); + super(user, new ElevatedUserContext(user.getPermissionsContext(), rolesToAdd)); } private ElevatedUser(User user, PermissionsContext ctx) diff --git a/api/src/org/labkey/api/security/LimitedUser.java b/api/src/org/labkey/api/security/LimitedUser.java index cf618ce638c..0febeaf697e 100644 --- a/api/src/org/labkey/api/security/LimitedUser.java +++ b/api/src/org/labkey/api/security/LimitedUser.java @@ -97,7 +97,7 @@ private LimitedUser( @JsonProperty("_lastLogin") Date lastLogin, @JsonProperty("_phone") String phone, @JsonProperty("_lastActivity") Date lastActivity, - @JsonProperty("_impersonationContext") PermissionsContext ctx + @JsonProperty("_permissionsContext") PermissionsContext ctx ) { super(name, userId, displayName, firstName, lastName, active, lastLogin, phone, lastActivity, ctx); diff --git a/api/src/org/labkey/api/security/RoleRestrictedUser.java b/api/src/org/labkey/api/security/RoleRestrictedUser.java new file mode 100644 index 00000000000..010d4003865 --- /dev/null +++ b/api/src/org/labkey/api/security/RoleRestrictedUser.java @@ -0,0 +1,38 @@ +package org.labkey.api.security; + +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.roles.Role; +import org.labkey.api.security.roles.RoleManager; + +import java.util.Set; +import java.util.stream.Stream; + +/** + * A cloned user that has the user's permissions in all that user's containers, but always restricted to the supplied + * role. This is useful for creating read-only API keys, editor-only API keys, etc. + */ +public class RoleRestrictedUser extends ClonedUser +{ + private static class RoleRestrictedPermissionsContext extends WrappedPermissionsContext + { + private final Role _role; + + private RoleRestrictedPermissionsContext(PermissionsContext ctx, Class clazz) + { + super(ctx); + _role = RoleManager.getRole(clazz); + } + + @Override + public Stream> filterPermissions(Stream> perms) + { + Set> rolePermissions = _role.getPermissions(); + return super.filterPermissions(perms).filter(rolePermissions::contains); + } + } + + public RoleRestrictedUser(User user, Class clazz) + { + super(user, new RoleRestrictedPermissionsContext(user.getPermissionsContext(), clazz)); + } +} diff --git a/api/src/org/labkey/api/security/RoleSet.java b/api/src/org/labkey/api/security/RoleSet.java index 58276a200ef..80fac9341b6 100644 --- a/api/src/org/labkey/api/security/RoleSet.java +++ b/api/src/org/labkey/api/security/RoleSet.java @@ -166,7 +166,7 @@ private void testImpersonateRoles(User adminUser, @Nullable Container project, C assertEquals(roles, reconstitutedRoleSet.getRoles()); RoleImpersonationContextFactory factory = new RoleImpersonationContextFactory(project, adminUser, roles, Collections.emptySet(), null); - impersonatingUser.setImpersonationContext(factory.getImpersonationContext()); + impersonatingUser.setPermissionsContext(factory.getImpersonationContext()); if (null == project) assertEquals(roles, impersonatingUser.getSiteRoles(ContainerManager.getRoot()).collect(Collectors.toSet())); diff --git a/api/src/org/labkey/api/security/SecurityManager.java b/api/src/org/labkey/api/security/SecurityManager.java index 84059487c84..24ff3dcd6f1 100644 --- a/api/src/org/labkey/api/security/SecurityManager.java +++ b/api/src/org/labkey/api/security/SecurityManager.java @@ -177,6 +177,7 @@ public class SecurityManager public static final String USER_ID_KEY = User.class.getName() + "$userId"; private static final String IMPERSONATION_CONTEXT_FACTORY_KEY = User.class.getName() + "$ImpersonationContextFactoryKey"; + private static final String ROLE_RESTRICTION_KEY = RoleRestrictedUser.class.getName() + "$RoleRestrictionKey"; private static final String AUTHENTICATION_VALIDATORS_KEY = SecurityManager.class.getName() + "$AuthenticationValidators"; private static final String AUTHENTICATION_METHOD = "SecurityManager.authenticationMethod"; @@ -517,8 +518,13 @@ public static User getSessionUser(HandshakeRequest request) if (null != factory) { - sessionUser.setImpersonationContext(factory.getImpersonationContext()); + sessionUser.setPermissionsContext(factory.getImpersonationContext()); } + + //noinspection unchecked + Class roleRestrictionClass = (Class) session.getAttribute(ROLE_RESTRICTION_KEY); + if (roleRestrictionClass != null) + sessionUser = new RoleRestrictedUser(sessionUser, roleRestrictionClass); } } @@ -586,7 +592,7 @@ public static Pair attemptAuthentication(HttpServletRe if (!sessionUser.isImpersonated() && "true".equalsIgnoreCase(request.getHeader("LabKey-Disallow-Global-Roles"))) { - sessionUser.setImpersonationContext(DisallowPrivilegedRolesContext.get()); + sessionUser.setPermissionsContext(DisallowPrivilegedRolesContext.get()); } HttpSession session = request.getSession(false); @@ -816,6 +822,8 @@ public static HttpSession setAuthenticatedUser(HttpServletRequest request, @Null newSession.setAttribute(PRIMARY_AUTHENTICATION_CONFIGURATION, configuration.getRowId()); newSession.setAttribute(USER_ATTRIBUTES_KEY, response.getUserAttributeMap()); newSession.setAttribute(AUTHENTICATION_PROPERTIES, response.getAuthenticationProperties()); + if (response.getRoleRestriction() != null) + newSession.setAttribute(ROLE_RESTRICTION_KEY, response.getRoleRestriction()); } return newSession; @@ -1121,7 +1129,7 @@ else if (email.getEmailAddress().indexOf("@") > 0) // Issue 25813: if two users are being inserted at the same time through the addUser method above, we can get deadlock on SQLServer so we // actively lock when doing this select to prevent it from promoting a lock up the chain. - SQLFragment select = new SQLFragment("SELECT UserId FROM " + core.getTableInfoUsersData()); + SQLFragment select = new SQLFragment("SELECT UserId FROM ").append(core.getTableInfoUsersData()); if (core.getSchema().getScope().getSqlDialect().isSqlServer()) select.append(" WITH (UPDLOCK)"); select.append(" WHERE DisplayName = ? AND UserId != ?"); @@ -3343,7 +3351,7 @@ public void testReadOnlyImpersonate() throws InvalidEmailException, UserManageme final User testUser = TestContext.get().getUser(); // this user is subsetted to only permit read permissions (see AllowedForReadOnlyUser) User user = addUser(new ValidEmail("impersonate@test.net"), null, false).getUser(); - user.setImpersonationContext(new ReadOnlyPermissionsContext()); + user.setPermissionsContext(new ReadOnlyPermissionsContext()); Container testFolder = null; diff --git a/api/src/org/labkey/api/security/User.java b/api/src/org/labkey/api/security/User.java index 32d8cfbd45d..394112bebd2 100644 --- a/api/src/org/labkey/api/security/User.java +++ b/api/src/org/labkey/api/security/User.java @@ -33,8 +33,8 @@ import org.labkey.api.security.permissions.AnalystPermission; import org.labkey.api.security.permissions.ApplicationAdminPermission; import org.labkey.api.security.permissions.BrowserDeveloperPermission; -import org.labkey.api.security.permissions.ImpersonatePermission; import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.ImpersonatePermission; import org.labkey.api.security.permissions.ImpersonatePrivilegedSiteRolesPermission; import org.labkey.api.security.permissions.InsertPermission; import org.labkey.api.security.permissions.Permission; @@ -409,7 +409,7 @@ public void setLastActivity(Date lastActivity) _lastActivity = lastActivity; } - void setImpersonationContext(PermissionsContext permissionsContext) + void setPermissionsContext(PermissionsContext permissionsContext) { _permissionsContext = permissionsContext; } diff --git a/api/src/org/labkey/api/security/WrappedPermissionsContext.java b/api/src/org/labkey/api/security/WrappedPermissionsContext.java index a33506a12af..ea37a76f9c0 100644 --- a/api/src/org/labkey/api/security/WrappedPermissionsContext.java +++ b/api/src/org/labkey/api/security/WrappedPermissionsContext.java @@ -15,7 +15,6 @@ */ package org.labkey.api.security; -import com.google.common.collect.Streams; import org.jetbrains.annotations.Nullable; import org.labkey.api.data.Container; import org.labkey.api.security.impersonation.ImpersonationContextFactory; @@ -24,26 +23,18 @@ import org.labkey.api.view.ActionURL; import org.labkey.api.view.NavTree; -import java.util.Set; import java.util.stream.Stream; /** - * Do not use this class directly; use ElevatedUser instead. + * See subclasses ElevatedUser and RoleRestrictedUser */ -public class WrappedPermissionsContext implements PermissionsContext +abstract class WrappedPermissionsContext implements PermissionsContext { private final PermissionsContext _delegate; - private final Set _additionalRoles; - public WrappedPermissionsContext(PermissionsContext delegate, Set additionalRoles) + public WrappedPermissionsContext(PermissionsContext delegate) { _delegate = delegate; - _additionalRoles = additionalRoles; - } - - public WrappedPermissionsContext(PermissionsContext delegate, Role additionalRole) - { - this(delegate, Set.of(additionalRole)); } @Override @@ -86,7 +77,7 @@ public PrincipalArray getGroups(User user) @Override public Stream getAssignedRoles(User user, SecurableResource resource) { - return Streams.concat(_additionalRoles.stream(), _delegate.getAssignedRoles(user, resource)); + return _delegate.getAssignedRoles(user, resource); } @Override diff --git a/api/src/org/labkey/api/security/impersonation/GroupImpersonationContextFactory.java b/api/src/org/labkey/api/security/impersonation/GroupImpersonationContextFactory.java index 47d4dd7e19e..20b0b086977 100644 --- a/api/src/org/labkey/api/security/impersonation/GroupImpersonationContextFactory.java +++ b/api/src/org/labkey/api/security/impersonation/GroupImpersonationContextFactory.java @@ -25,7 +25,6 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.security.Group; import org.labkey.api.security.GroupMembershipCache; -import org.labkey.api.security.PermissionsContext; import org.labkey.api.security.PrincipalArray; import org.labkey.api.security.SecurableResource; import org.labkey.api.security.SecurityManager; @@ -77,7 +76,7 @@ public GroupImpersonationContextFactory(@Nullable Container project, User adminU } @Override - public PermissionsContext getImpersonationContext() + public AbstractImpersonationContext getImpersonationContext() { Container project = (null != _projectId ? ContainerManager.getForId(_projectId) : null); Group group = SecurityManager.getGroup(_groupId); diff --git a/api/src/org/labkey/api/security/impersonation/ImpersonationContextFactory.java b/api/src/org/labkey/api/security/impersonation/ImpersonationContextFactory.java index 02a9159a24c..14bf54210bd 100644 --- a/api/src/org/labkey/api/security/impersonation/ImpersonationContextFactory.java +++ b/api/src/org/labkey/api/security/impersonation/ImpersonationContextFactory.java @@ -16,17 +16,16 @@ package org.labkey.api.security.impersonation; import jakarta.servlet.http.HttpServletRequest; -import org.labkey.api.security.PermissionsContext; import org.labkey.api.security.User; import org.labkey.api.view.ViewContext; import java.io.Serializable; -// We store implementations of this interface in session and construct ImpersonationContexts at each request. This -// protects us somewhat from user, container, etc. objects getting out-of-date. +// We store implementations of this interface in session and construct AbstractImpersonationContexts at each request. +// This protects us somewhat from user, container, etc. objects getting out-of-date. public interface ImpersonationContextFactory extends Serializable { - PermissionsContext getImpersonationContext(); + AbstractImpersonationContext getImpersonationContext(); void startImpersonating(ViewContext context); void stopImpersonating(HttpServletRequest request); User getAdminUser(); diff --git a/api/src/org/labkey/api/security/impersonation/RoleImpersonationContextFactory.java b/api/src/org/labkey/api/security/impersonation/RoleImpersonationContextFactory.java index cc50b6b4945..7ef34f13dbf 100644 --- a/api/src/org/labkey/api/security/impersonation/RoleImpersonationContextFactory.java +++ b/api/src/org/labkey/api/security/impersonation/RoleImpersonationContextFactory.java @@ -24,7 +24,6 @@ import org.labkey.api.audit.AuditLogService; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; -import org.labkey.api.security.PermissionsContext; import org.labkey.api.security.PrincipalArray; import org.labkey.api.security.RoleSet; import org.labkey.api.security.SecurableResource; @@ -101,7 +100,7 @@ public RoleImpersonationContextFactory(@Nullable Container project, User adminUs } @Override - public PermissionsContext getImpersonationContext() + public AbstractImpersonationContext getImpersonationContext() { Container project = (null != _projectId ? ContainerManager.getForId(_projectId) : null); diff --git a/api/src/org/labkey/api/security/impersonation/UserImpersonationContextFactory.java b/api/src/org/labkey/api/security/impersonation/UserImpersonationContextFactory.java index 5b2b41fc610..d80e81d45ea 100644 --- a/api/src/org/labkey/api/security/impersonation/UserImpersonationContextFactory.java +++ b/api/src/org/labkey/api/security/impersonation/UserImpersonationContextFactory.java @@ -24,7 +24,6 @@ import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; import org.labkey.api.security.GroupManager; -import org.labkey.api.security.PermissionsContext; import org.labkey.api.security.PrincipalArray; import org.labkey.api.security.SecurableResource; import org.labkey.api.security.SecurityManager; @@ -84,7 +83,7 @@ public User getAdminUser() } @Override - public PermissionsContext getImpersonationContext() + public AbstractImpersonationContext getImpersonationContext() { Container project = (null != _projectId ? ContainerManager.getForId(_projectId) : null); diff --git a/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java b/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java index f9f9283cf48..68fada54bf6 100644 --- a/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java +++ b/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java @@ -36,6 +36,7 @@ import org.labkey.api.security.UserManager; import org.labkey.api.security.ValidEmail; import org.labkey.api.security.ValidEmail.InvalidEmailException; +import org.labkey.api.security.roles.Role; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.StandardStartupPropertyHandler; import org.labkey.api.settings.StartupPropertyEntry; @@ -122,7 +123,8 @@ public boolean isFicamApproved() // API keys are exempt from secondary authentication, Issue 48764 ret = AuthenticationResponse.success(configuration, auth.getUser()).setSuccessDetails(UserManager.UserAuditEvent.API_KEY) .setRequireSecondary(false) - .setAuthenticationProperties(Map.of(ApiKeyManager.API_KEY_ROW_ID, auth.rowId())); + .setAuthenticationProperties(Map.of(ApiKeyManager.API_KEY_ROW_ID, auth.rowId())) + .setRoleRestriction(auth.roleRestrictionClass()); // Update core.ApiKeys.LastUsed (throttled) API_KEY_LAST_USED_THROTTLE.execute(password); @@ -184,7 +186,8 @@ public boolean isFicamApproved() } } - return AuthenticationResponse.success(configuration, user); + Class roleRestriction = null; // TODO: Temp for testing - set a breakpoint and assign a value to test + return AuthenticationResponse.success(configuration, user).setRoleRestriction(roleRestriction); } } From 0186b8c41c2b5c2d2123efbb4ceb6d8b901620e9 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 30 Jun 2026 16:18:23 -0700 Subject: [PATCH 02/15] Use custom API action to convey a relevant subset of roles --- .../labkey/api/security/ApiKeyManager.java | 19 +++++++- core/package-lock.json | 8 ++-- core/package.json | 2 +- .../core/security/SecurityController.java | 47 ++++++++++++++++++- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/api/src/org/labkey/api/security/ApiKeyManager.java b/api/src/org/labkey/api/security/ApiKeyManager.java index 70998d8b24d..b1951d100a7 100644 --- a/api/src/org/labkey/api/security/ApiKeyManager.java +++ b/api/src/org/labkey/api/security/ApiKeyManager.java @@ -86,9 +86,9 @@ private ApiKeyManager() * @param user User to be associated with the new API key. * @return An API key that expires after the admin-configured duration */ - public @NotNull String createKey(@NotNull User user, @Nullable String description) + public @NotNull String createKey(@NotNull User user, @Nullable String description, @Nullable Class restrictedRole) { - return createKey(user, AppProps.getInstance().getApiKeyExpirationSeconds(), description); + return createKey(user, AppProps.getInstance().getApiKeyExpirationSeconds(), description, restrictedRole); } /** @@ -98,6 +98,18 @@ private ApiKeyManager() * @return An API key that expires after the specified number of seconds */ public @NotNull String createKey(@NotNull User user, int expirationSeconds, @Nullable String description) + { + return createKey(user, expirationSeconds, description, null); + } + + /** + * Create an API key associated with a user and persist it in the database. + * @param user User to be associated with the new API key. + * @param expirationSeconds Number of seconds until expiration. -1 means no expiration. + * @param restrictedRole Role class that limits this API key's permissions. null means no restrictions. + * @return An API key that expires after the specified number of seconds + */ + public @NotNull String createKey(@NotNull User user, int expirationSeconds, @Nullable String description, @Nullable Class restrictedRole) { if (user.isGuest()) throw new IllegalStateException("Can't create an API key for a guest"); @@ -121,6 +133,9 @@ private ApiKeyManager() if (description != null) map.put("Description", StringUtils.abbreviate(description.trim(), 256)); + if (restrictedRole != null) + map.put("RestrictedRole", restrictedRole.getName()); + try (Transaction t = CoreSchema.getInstance().getScope().beginTransaction(TRANSACTION_KIND)) { Table.insert(user, CoreSchema.getInstance().getTableAPIKeys(), map); diff --git a/core/package-lock.json b/core/package-lock.json index 3bab6585671..2fe922e0d6b 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.45.2", + "@labkey/components": "7.45.3-fb-role-restricted-api-keys.1", "@labkey/themes": "1.9.4" }, "devDependencies": { @@ -3789,9 +3789,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.45.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.45.2.tgz", - "integrity": "sha512-o/U7wotLaaoqXFhUFTxzgGeU1OVOwq16PBzCF/Ti/+Z8WW3OxAsAUCK/YqQMLkGu5sgH+ZOHoxa6tO3+NjipBw==", + "version": "7.45.3-fb-role-restricted-api-keys.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.45.3-fb-role-restricted-api-keys.1.tgz", + "integrity": "sha512-ECxFtC8Jcf/XbzD0PmH4FqRGaWKDsTdcWx6Ey3Ni7h1PmdQ+blkTmwzQdp7PgN6ZpNGXx8kiiExlJszHsvN6hQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index 48eae14a889..f1a25573eeb 100644 --- a/core/package.json +++ b/core/package.json @@ -20,7 +20,7 @@ "lint-branch-fix": "node lint.diff.mjs --currentBranch --fix" }, "dependencies": { - "@labkey/components": "7.45.2", + "@labkey/components": "7.45.3-fb-role-restricted-api-keys.1", "@labkey/themes": "1.9.4" }, "devDependencies": { diff --git a/core/src/org/labkey/core/security/SecurityController.java b/core/src/org/labkey/core/security/SecurityController.java index 023e4c30eab..68a7d7c38c1 100644 --- a/core/src/org/labkey/core/security/SecurityController.java +++ b/core/src/org/labkey/core/security/SecurityController.java @@ -47,6 +47,7 @@ import org.labkey.api.audit.provider.GroupAuditProvider; import org.labkey.api.audit.provider.GroupAuditProvider.GroupAuditEvent; import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.compliance.ComplianceService; import org.labkey.api.data.ColumnInfo; import org.labkey.api.data.Container; @@ -115,9 +116,13 @@ import org.labkey.api.security.permissions.UpdateUserPermission; import org.labkey.api.security.permissions.UserManagementPermission; import org.labkey.api.security.roles.ApplicationAdminRole; +import org.labkey.api.security.roles.AuthorRole; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.security.roles.EditorWithoutDeleteRole; import org.labkey.api.security.roles.FolderAdminRole; import org.labkey.api.security.roles.NoPermissionsRole; import org.labkey.api.security.roles.ProjectAdminRole; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.security.roles.Role; import org.labkey.api.security.roles.RoleManager; import org.labkey.api.security.roles.SiteAdminRole; @@ -164,6 +169,7 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.apache.commons.lang3.StringUtils.trimToNull; @@ -2288,10 +2294,38 @@ public void addNavTrail(NavTree root) } } + record RestrictedRole(Class roleClass) + { + String displayName() + { + return RoleManager.getRole(roleClass).getDisplayName(); + } + } + + private static final Map RESTRICTED_ROLE_MAP = Stream.of( + ReaderRole.class, + AuthorRole.class, + EditorWithoutDeleteRole.class, + EditorRole.class + ).collect(LabKeyCollectors.toLinkedMap(Class::getName, RestrictedRole::new)); + + @RequiresLogin + public static class GetApiKeyRolesAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + return RESTRICTED_ROLE_MAP.entrySet().stream() + .map(e -> new JSONObject(Map.of("uniqueName", e.getKey(), "displayName", e.getValue().displayName()))) + .collect(LabKeyCollectors.toJSONArray()); + } + } + public static class CreateApiKeyForm { private String _type; private String _description; + private String _role; public String getType() { @@ -2314,6 +2348,16 @@ public void setDescription(String description) { _description = description; } + + public String getRole() + { + return _role; + } + + public void setRole(String role) + { + _role = role; + } } @RequiresLogin @@ -2330,7 +2374,8 @@ public Object execute(CreateApiKeyForm form, BindException errors) if (!AppProps.getInstance().isAllowApiKeys()) throw new NotFoundException("Creation of API keys is disabled"); - apiKey = ApiKeyManager.get().createKey(getUser(), form.getDescription()); + RestrictedRole rr = RESTRICTED_ROLE_MAP.get(form.getRole()); + apiKey = ApiKeyManager.get().createKey(getUser(), form.getDescription(), rr != null ? rr.roleClass() : null); break; case "session": if (!AppProps.getInstance().isAllowSessionKeys()) From c326bad9f33ee4a6e74d72ce78f756923973689d Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 30 Jun 2026 16:49:13 -0700 Subject: [PATCH 03/15] Bump labkey-ui-components version --- core/package-lock.json | 8 ++++---- core/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/package-lock.json b/core/package-lock.json index 2fe922e0d6b..efff98f45bd 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.45.3-fb-role-restricted-api-keys.1", + "@labkey/components": "7.45.3-fb-role-restricted-api-keys.2", "@labkey/themes": "1.9.4" }, "devDependencies": { @@ -3789,9 +3789,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.45.3-fb-role-restricted-api-keys.1", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.45.3-fb-role-restricted-api-keys.1.tgz", - "integrity": "sha512-ECxFtC8Jcf/XbzD0PmH4FqRGaWKDsTdcWx6Ey3Ni7h1PmdQ+blkTmwzQdp7PgN6ZpNGXx8kiiExlJszHsvN6hQ==", + "version": "7.45.3-fb-role-restricted-api-keys.2", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.45.3-fb-role-restricted-api-keys.2.tgz", + "integrity": "sha512-Y63kErfjETWYIm4aezV4wXsLR1OS3mozgTBl9uztyw8NE5sHtMu5udaaz583rqSmBBXjFizComwt4bgo1Q5h1A==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index f1a25573eeb..b7877f2b704 100644 --- a/core/package.json +++ b/core/package.json @@ -20,7 +20,7 @@ "lint-branch-fix": "node lint.diff.mjs --currentBranch --fix" }, "dependencies": { - "@labkey/components": "7.45.3-fb-role-restricted-api-keys.1", + "@labkey/components": "7.45.3-fb-role-restricted-api-keys.2", "@labkey/themes": "1.9.4" }, "devDependencies": { From 93368a8b19ff99f2126196dd247df09d2cbe3745 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 1 Jul 2026 09:53:37 -0700 Subject: [PATCH 04/15] Terminology: restriction role --- .../labkey/api/security/ApiKeyManager.java | 14 ++++---- .../api/security/AuthenticationProvider.java | 12 +++---- .../api/security/RoleRestrictedUser.java | 10 +++--- .../labkey/api/security/SecurityManager.java | 12 +++---- .../login/DbLoginAuthenticationProvider.java | 6 ++-- .../core/security/SecurityController.java | 36 +++++++++---------- 6 files changed, 44 insertions(+), 46 deletions(-) diff --git a/api/src/org/labkey/api/security/ApiKeyManager.java b/api/src/org/labkey/api/security/ApiKeyManager.java index b1951d100a7..1a78b353554 100644 --- a/api/src/org/labkey/api/security/ApiKeyManager.java +++ b/api/src/org/labkey/api/security/ApiKeyManager.java @@ -86,9 +86,9 @@ private ApiKeyManager() * @param user User to be associated with the new API key. * @return An API key that expires after the admin-configured duration */ - public @NotNull String createKey(@NotNull User user, @Nullable String description, @Nullable Class restrictedRole) + public @NotNull String createKey(@NotNull User user, @Nullable String description, @Nullable Class restrictionRole) { - return createKey(user, AppProps.getInstance().getApiKeyExpirationSeconds(), description, restrictedRole); + return createKey(user, AppProps.getInstance().getApiKeyExpirationSeconds(), description, restrictionRole); } /** @@ -106,10 +106,10 @@ private ApiKeyManager() * Create an API key associated with a user and persist it in the database. * @param user User to be associated with the new API key. * @param expirationSeconds Number of seconds until expiration. -1 means no expiration. - * @param restrictedRole Role class that limits this API key's permissions. null means no restrictions. + * @param restrictionRole Role class that limits this API key's permissions. null means no restrictions. * @return An API key that expires after the specified number of seconds */ - public @NotNull String createKey(@NotNull User user, int expirationSeconds, @Nullable String description, @Nullable Class restrictedRole) + public @NotNull String createKey(@NotNull User user, int expirationSeconds, @Nullable String description, @Nullable Class restrictionRole) { if (user.isGuest()) throw new IllegalStateException("Can't create an API key for a guest"); @@ -133,8 +133,8 @@ private ApiKeyManager() if (description != null) map.put("Description", StringUtils.abbreviate(description.trim(), 256)); - if (restrictedRole != null) - map.put("RestrictedRole", restrictedRole.getName()); + if (restrictionRole != null) + map.put("RestrictionRole", restrictionRole.getName()); try (Transaction t = CoreSchema.getInstance().getScope().beginTransaction(TRANSACTION_KIND)) { @@ -209,7 +209,7 @@ public void updateLastUsed(String apikey) } } - public record ApiKeyAuthentication(int createdBy, int rowId, @Nullable Class roleRestrictionClass) + public record ApiKeyAuthentication(int createdBy, int rowId, @Nullable Class restrictionRole) { public User getUser() { diff --git a/api/src/org/labkey/api/security/AuthenticationProvider.java b/api/src/org/labkey/api/security/AuthenticationProvider.java index b4c6c20a4ac..64f8a294d86 100644 --- a/api/src/org/labkey/api/security/AuthenticationProvider.java +++ b/api/src/org/labkey/api/security/AuthenticationProvider.java @@ -307,7 +307,7 @@ class AuthenticationResponse private @Nullable ActionURL _redirectURL = null; private @NotNull Map _userAttributeMap = Collections.emptyMap(); // A case-insensitive map of attribute names and values associated with the user private @NotNull Map _authenticationProperties = Collections.emptyMap(); - private @Nullable Class _roleRestriction; // Limit user's permissions to those of this role + private @Nullable Class _restrictionRole; // Limit user's permissions to those of this role private boolean _requireSecondary = true; // Require secondary authentication private boolean _reauth = false; private @Nullable String _successDetails = null; // An optional string describing how successful authentication took place, which will @@ -430,19 +430,19 @@ public AuthenticationResponse setAuthenticationProperties(Map ma } /** - * Get the role restriction class, if present + * Get the restriction role class, if present */ - public @Nullable Class getRoleRestriction() + public @Nullable Class getRestrictionRole() { - return _roleRestriction; + return _restrictionRole; } /** * Set a role that restricts this user's permissions */ - public AuthenticationResponse setRoleRestriction(Class clazz) + public AuthenticationResponse setRestrictionRole(Class clazz) { - _roleRestriction = clazz; + _restrictionRole = clazz; return this; } diff --git a/api/src/org/labkey/api/security/RoleRestrictedUser.java b/api/src/org/labkey/api/security/RoleRestrictedUser.java index 010d4003865..8c971abee00 100644 --- a/api/src/org/labkey/api/security/RoleRestrictedUser.java +++ b/api/src/org/labkey/api/security/RoleRestrictedUser.java @@ -8,7 +8,7 @@ import java.util.stream.Stream; /** - * A cloned user that has the user's permissions in all that user's containers, but always restricted to the supplied + * A cloned user that has the user's permissions in all the user's containers, but always restricted to the supplied * role. This is useful for creating read-only API keys, editor-only API keys, etc. */ public class RoleRestrictedUser extends ClonedUser @@ -17,10 +17,10 @@ private static class RoleRestrictedPermissionsContext extends WrappedPermissions { private final Role _role; - private RoleRestrictedPermissionsContext(PermissionsContext ctx, Class clazz) + private RoleRestrictedPermissionsContext(PermissionsContext ctx, Class restrictionRole) { super(ctx); - _role = RoleManager.getRole(clazz); + _role = RoleManager.getRole(restrictionRole); } @Override @@ -31,8 +31,8 @@ public Stream> filterPermissions(Stream clazz) + public RoleRestrictedUser(User user, Class restrictionRole) { - super(user, new RoleRestrictedPermissionsContext(user.getPermissionsContext(), clazz)); + super(user, new RoleRestrictedPermissionsContext(user.getPermissionsContext(), restrictionRole)); } } diff --git a/api/src/org/labkey/api/security/SecurityManager.java b/api/src/org/labkey/api/security/SecurityManager.java index 24ff3dcd6f1..6b7b22877f7 100644 --- a/api/src/org/labkey/api/security/SecurityManager.java +++ b/api/src/org/labkey/api/security/SecurityManager.java @@ -177,7 +177,7 @@ public class SecurityManager public static final String USER_ID_KEY = User.class.getName() + "$userId"; private static final String IMPERSONATION_CONTEXT_FACTORY_KEY = User.class.getName() + "$ImpersonationContextFactoryKey"; - private static final String ROLE_RESTRICTION_KEY = RoleRestrictedUser.class.getName() + "$RoleRestrictionKey"; + private static final String RESTRICTION_ROLE_KEY = RoleRestrictedUser.class.getName() + "$RestrictionRoleKey"; private static final String AUTHENTICATION_VALIDATORS_KEY = SecurityManager.class.getName() + "$AuthenticationValidators"; private static final String AUTHENTICATION_METHOD = "SecurityManager.authenticationMethod"; @@ -522,9 +522,9 @@ public static User getSessionUser(HandshakeRequest request) } //noinspection unchecked - Class roleRestrictionClass = (Class) session.getAttribute(ROLE_RESTRICTION_KEY); - if (roleRestrictionClass != null) - sessionUser = new RoleRestrictedUser(sessionUser, roleRestrictionClass); + Class restrictionRole = (Class) session.getAttribute(RESTRICTION_ROLE_KEY); + if (restrictionRole != null) + sessionUser = new RoleRestrictedUser(sessionUser, restrictionRole); } } @@ -822,8 +822,8 @@ public static HttpSession setAuthenticatedUser(HttpServletRequest request, @Null newSession.setAttribute(PRIMARY_AUTHENTICATION_CONFIGURATION, configuration.getRowId()); newSession.setAttribute(USER_ATTRIBUTES_KEY, response.getUserAttributeMap()); newSession.setAttribute(AUTHENTICATION_PROPERTIES, response.getAuthenticationProperties()); - if (response.getRoleRestriction() != null) - newSession.setAttribute(ROLE_RESTRICTION_KEY, response.getRoleRestriction()); + if (response.getRestrictionRole() != null) + newSession.setAttribute(RESTRICTION_ROLE_KEY, response.getRestrictionRole()); } return newSession; diff --git a/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java b/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java index 68fada54bf6..2f02beab4c9 100644 --- a/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java +++ b/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java @@ -36,7 +36,6 @@ import org.labkey.api.security.UserManager; import org.labkey.api.security.ValidEmail; import org.labkey.api.security.ValidEmail.InvalidEmailException; -import org.labkey.api.security.roles.Role; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.StandardStartupPropertyHandler; import org.labkey.api.settings.StartupPropertyEntry; @@ -124,7 +123,7 @@ public boolean isFicamApproved() ret = AuthenticationResponse.success(configuration, auth.getUser()).setSuccessDetails(UserManager.UserAuditEvent.API_KEY) .setRequireSecondary(false) .setAuthenticationProperties(Map.of(ApiKeyManager.API_KEY_ROW_ID, auth.rowId())) - .setRoleRestriction(auth.roleRestrictionClass()); + .setRestrictionRole(auth.restrictionRole()); // Update core.ApiKeys.LastUsed (throttled) API_KEY_LAST_USED_THROTTLE.execute(password); @@ -186,8 +185,7 @@ public boolean isFicamApproved() } } - Class roleRestriction = null; // TODO: Temp for testing - set a breakpoint and assign a value to test - return AuthenticationResponse.success(configuration, user).setRoleRestriction(roleRestriction); + return AuthenticationResponse.success(configuration, user); } } diff --git a/core/src/org/labkey/core/security/SecurityController.java b/core/src/org/labkey/core/security/SecurityController.java index 68a7d7c38c1..0058cc966a4 100644 --- a/core/src/org/labkey/core/security/SecurityController.java +++ b/core/src/org/labkey/core/security/SecurityController.java @@ -2294,7 +2294,7 @@ public void addNavTrail(NavTree root) } } - record RestrictedRole(Class roleClass) + record RestrictionRole(Class roleClass) { String displayName() { @@ -2302,12 +2302,12 @@ String displayName() } } - private static final Map RESTRICTED_ROLE_MAP = Stream.of( + private static final Map RESTRICTION_ROLE_MAP = Stream.of( ReaderRole.class, AuthorRole.class, EditorWithoutDeleteRole.class, EditorRole.class - ).collect(LabKeyCollectors.toLinkedMap(Class::getName, RestrictedRole::new)); + ).collect(LabKeyCollectors.toLinkedMap(Class::getName, RestrictionRole::new)); @RequiresLogin public static class GetApiKeyRolesAction extends ReadOnlyApiAction @@ -2315,7 +2315,7 @@ public static class GetApiKeyRolesAction extends ReadOnlyApiAction @Override public Object execute(Object o, BindException errors) throws Exception { - return RESTRICTED_ROLE_MAP.entrySet().stream() + return RESTRICTION_ROLE_MAP.entrySet().stream() .map(e -> new JSONObject(Map.of("uniqueName", e.getKey(), "displayName", e.getValue().displayName()))) .collect(LabKeyCollectors.toJSONArray()); } @@ -2374,7 +2374,7 @@ public Object execute(CreateApiKeyForm form, BindException errors) if (!AppProps.getInstance().isAllowApiKeys()) throw new NotFoundException("Creation of API keys is disabled"); - RestrictedRole rr = RESTRICTED_ROLE_MAP.get(form.getRole()); + RestrictionRole rr = RESTRICTION_ROLE_MAP.get(form.getRole()); apiKey = ApiKeyManager.get().createKey(getUser(), form.getDescription(), rr != null ? rr.roleClass() : null); break; case "session": @@ -2410,29 +2410,29 @@ public void testActionPermissions() // @RequiresPermission(ReadPermission.class) assertForReadPermission(user, false, - new CompleteUserReadAction() + new CompleteUserReadAction() ); // @RequiresPermission(AdminPermission.class) assertForAdminPermission(user, - new PermissionsAction(), - new StandardDeleteGroupAction(), + new PermissionsAction(), + new StandardDeleteGroupAction(), controller.new GroupAction(), - new CompleteMemberAction(), - new CompleteUserAction(), - new GroupExportAction(), - new GroupPermissionAction(), - new UpdatePermissionsAction(), - new ShowRegistrationEmailAction(), - new GroupDiagramAction(), - new FolderAccessAction() + new CompleteMemberAction(), + new CompleteUserAction(), + new GroupExportAction(), + new GroupPermissionAction(), + new UpdatePermissionsAction(), + new ShowRegistrationEmailAction(), + new GroupDiagramAction(), + new FolderAccessAction() ); // @RequiresPermission(UserManagementPermission.class) assertForUserPermissions(user, controller.new AddUsersAction(), - new ShowResetEmailAction(), - new AdminResetPasswordAction() + new ShowResetEmailAction(), + new AdminResetPasswordAction() ); } From 6e9eed85e7a2dff49623f79891efb7d476a5e4b3 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 1 Jul 2026 10:14:44 -0700 Subject: [PATCH 05/15] Read the role column --- api/src/org/labkey/api/security/ApiKeyManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/security/ApiKeyManager.java b/api/src/org/labkey/api/security/ApiKeyManager.java index 1a78b353554..c38d24e166d 100644 --- a/api/src/org/labkey/api/security/ApiKeyManager.java +++ b/api/src/org/labkey/api/security/ApiKeyManager.java @@ -232,7 +232,7 @@ public User getUser() try (Transaction t = CoreSchema.getInstance().getScope().beginTransaction(TRANSACTION_KIND)) { - ret = new TableSelector(CoreSchema.getInstance().getTableAPIKeys(), Set.of("CreatedBy", "RowId"), filter, null).getObject(ApiKeyAuthentication.class); + ret = new TableSelector(CoreSchema.getInstance().getTableAPIKeys(), Set.of("CreatedBy", "RowId", "RestrictionRole"), filter, null).getObject(ApiKeyAuthentication.class); t.commit(); } From fc96d71a768770d2c298d14caf776bbe224d3506 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 1 Jul 2026 10:53:22 -0700 Subject: [PATCH 06/15] Add the new column to query tables and core.xml --- core/resources/schemas/core.xml | 1 + core/src/org/labkey/core/query/ApiKeysTableInfo.java | 1 + 2 files changed, 2 insertions(+) diff --git a/core/resources/schemas/core.xml b/core/resources/schemas/core.xml index df2a6ef7db3..917d39fffb4 100644 --- a/core/resources/schemas/core.xml +++ b/core/resources/schemas/core.xml @@ -705,6 +705,7 @@ + diff --git a/core/src/org/labkey/core/query/ApiKeysTableInfo.java b/core/src/org/labkey/core/query/ApiKeysTableInfo.java index bbdc93a622e..e47848d189f 100644 --- a/core/src/org/labkey/core/query/ApiKeysTableInfo.java +++ b/core/src/org/labkey/core/query/ApiKeysTableInfo.java @@ -44,6 +44,7 @@ public ApiKeysTableInfo(@NotNull CoreQuerySchema schema, boolean filterToCurrent addWrapColumn(getRealTable().getColumn("Expiration")); addWrapColumn(getRealTable().getColumn("LastUsed")); addWrapColumn(getRealTable().getColumn("Description")); + addWrapColumn(getRealTable().getColumn("RestrictionRole")); if (filterToCurrentUser) { From 10af8ca58f2b7b7ec57e60baf1e165ea7e28fc41 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 1 Jul 2026 15:32:23 -0700 Subject: [PATCH 07/15] Add core.ApiKeys.RestrictionRole --- core/module.properties | 2 +- .../schemas/dbscripts/postgresql/core-26.006-26.007.sql | 1 + .../schemas/dbscripts/sqlserver/core-26.006-26.007.sql | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 core/resources/schemas/dbscripts/postgresql/core-26.006-26.007.sql create mode 100644 core/resources/schemas/dbscripts/sqlserver/core-26.006-26.007.sql diff --git a/core/module.properties b/core/module.properties index 0be23cf2635..0731d77b149 100644 --- a/core/module.properties +++ b/core/module.properties @@ -1,6 +1,6 @@ Name: Core ModuleClass: org.labkey.core.CoreModule -SchemaVersion: 26.006 +SchemaVersion: 26.007 Label: Administration and Essential Services Description: The Core module provides central services such as login, \ security, administration, folder management, user management, \ diff --git a/core/resources/schemas/dbscripts/postgresql/core-26.006-26.007.sql b/core/resources/schemas/dbscripts/postgresql/core-26.006-26.007.sql new file mode 100644 index 00000000000..e761ac28830 --- /dev/null +++ b/core/resources/schemas/dbscripts/postgresql/core-26.006-26.007.sql @@ -0,0 +1 @@ +ALTER TABLE core.ApiKeys ADD RestrictionRole VARCHAR(256); \ No newline at end of file diff --git a/core/resources/schemas/dbscripts/sqlserver/core-26.006-26.007.sql b/core/resources/schemas/dbscripts/sqlserver/core-26.006-26.007.sql new file mode 100644 index 00000000000..1e2671c861b --- /dev/null +++ b/core/resources/schemas/dbscripts/sqlserver/core-26.006-26.007.sql @@ -0,0 +1 @@ +ALTER TABLE core.ApiKeys ADD RestrictionRole NVARCHAR(256); \ No newline at end of file From 1a1ad57f64af457b604d5556b2db123aee48b7b8 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 2 Jul 2026 11:27:26 -0700 Subject: [PATCH 08/15] Claude feedback + refactoring. Push permissions-restricted user creation into ApiKeyManager to guarantee consistency. Make PermissionsContexts responsible for stashing session attributes. Stash permissions instead of a role for simplicity. --- .../labkey/api/security/ApiKeyManager.java | 14 +++++- .../api/security/AuthenticationProvider.java | 17 ------- .../api/security/PermissionsContext.java | 6 +++ .../security/PermissionsRestrictedUser.java | 46 +++++++++++++++++++ .../api/security/RoleRestrictedUser.java | 38 --------------- .../labkey/api/security/SecurityManager.java | 11 ++--- .../login/DbLoginAuthenticationProvider.java | 6 +-- .../core/security/SecurityController.java | 10 +++- 8 files changed, 81 insertions(+), 67 deletions(-) create mode 100644 api/src/org/labkey/api/security/PermissionsRestrictedUser.java delete mode 100644 api/src/org/labkey/api/security/RoleRestrictedUser.java diff --git a/api/src/org/labkey/api/security/ApiKeyManager.java b/api/src/org/labkey/api/security/ApiKeyManager.java index c38d24e166d..c3dfc895b77 100644 --- a/api/src/org/labkey/api/security/ApiKeyManager.java +++ b/api/src/org/labkey/api/security/ApiKeyManager.java @@ -40,6 +40,7 @@ import org.labkey.api.security.UserManager.SessionHandler; import org.labkey.api.security.ValidEmail.InvalidEmailException; import org.labkey.api.security.roles.Role; +import org.labkey.api.security.roles.RoleManager; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.LenientStartupPropertyHandler; import org.labkey.api.settings.StartupProperty; @@ -50,6 +51,7 @@ import org.labkey.api.util.SystemMaintenance.MaintenanceTask; import org.labkey.api.util.TestContext; import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.NotFoundException; import org.labkey.api.view.UnauthorizedException; import java.time.Instant; @@ -213,7 +215,17 @@ public record ApiKeyAuthentication(int createdBy, int rowId, @Nullable Class ma return this; } - /** - * Get the restriction role class, if present - */ - public @Nullable Class getRestrictionRole() - { - return _restrictionRole; - } - - /** - * Set a role that restricts this user's permissions - */ - public AuthenticationResponse setRestrictionRole(Class clazz) - { - _restrictionRole = clazz; - return this; - } - public @Nullable String getSuccessDetails() { return _successDetails; diff --git a/api/src/org/labkey/api/security/PermissionsContext.java b/api/src/org/labkey/api/security/PermissionsContext.java index 9c6eeb86095..f4fc81021e9 100644 --- a/api/src/org/labkey/api/security/PermissionsContext.java +++ b/api/src/org/labkey/api/security/PermissionsContext.java @@ -16,6 +16,7 @@ package org.labkey.api.security; import com.google.common.collect.Streams; +import jakarta.servlet.http.HttpSession; import org.jetbrains.annotations.Nullable; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; @@ -87,4 +88,9 @@ default Stream> filterPermissions(Stream> _allowedPermissions; + + private RoleRestrictedPermissionsContext(PermissionsContext ctx, Set> allowedPermissions) + { + super(ctx); + _allowedPermissions = allowedPermissions; + } + + @Override + public Stream> filterPermissions(Stream> perms) + { + return super.filterPermissions(perms).filter(_allowedPermissions::contains); + } + + @Override + public void modifySession(HttpSession session) + { + super.modifySession(session); + session.setAttribute(ALLOWED_PERMISSIONS_KEY, _allowedPermissions); + } + } + + public PermissionsRestrictedUser(User user, @NotNull Set> allowedPermissions) + { + super(user, new RoleRestrictedPermissionsContext(user.getPermissionsContext(), allowedPermissions)); + } +} diff --git a/api/src/org/labkey/api/security/RoleRestrictedUser.java b/api/src/org/labkey/api/security/RoleRestrictedUser.java deleted file mode 100644 index 8c971abee00..00000000000 --- a/api/src/org/labkey/api/security/RoleRestrictedUser.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.labkey.api.security; - -import org.labkey.api.security.permissions.Permission; -import org.labkey.api.security.roles.Role; -import org.labkey.api.security.roles.RoleManager; - -import java.util.Set; -import java.util.stream.Stream; - -/** - * A cloned user that has the user's permissions in all the user's containers, but always restricted to the supplied - * role. This is useful for creating read-only API keys, editor-only API keys, etc. - */ -public class RoleRestrictedUser extends ClonedUser -{ - private static class RoleRestrictedPermissionsContext extends WrappedPermissionsContext - { - private final Role _role; - - private RoleRestrictedPermissionsContext(PermissionsContext ctx, Class restrictionRole) - { - super(ctx); - _role = RoleManager.getRole(restrictionRole); - } - - @Override - public Stream> filterPermissions(Stream> perms) - { - Set> rolePermissions = _role.getPermissions(); - return super.filterPermissions(perms).filter(rolePermissions::contains); - } - } - - public RoleRestrictedUser(User user, Class restrictionRole) - { - super(user, new RoleRestrictedPermissionsContext(user.getPermissionsContext(), restrictionRole)); - } -} diff --git a/api/src/org/labkey/api/security/SecurityManager.java b/api/src/org/labkey/api/security/SecurityManager.java index 6b7b22877f7..1f9cc19f85a 100644 --- a/api/src/org/labkey/api/security/SecurityManager.java +++ b/api/src/org/labkey/api/security/SecurityManager.java @@ -148,6 +148,7 @@ import java.util.stream.Stream; import static org.labkey.api.action.SpringActionController.ERROR_MSG; +import static org.labkey.api.security.PermissionsRestrictedUser.ALLOWED_PERMISSIONS_KEY; import static org.labkey.api.util.IntegerUtils.asInteger; /** @@ -177,7 +178,6 @@ public class SecurityManager public static final String USER_ID_KEY = User.class.getName() + "$userId"; private static final String IMPERSONATION_CONTEXT_FACTORY_KEY = User.class.getName() + "$ImpersonationContextFactoryKey"; - private static final String RESTRICTION_ROLE_KEY = RoleRestrictedUser.class.getName() + "$RestrictionRoleKey"; private static final String AUTHENTICATION_VALIDATORS_KEY = SecurityManager.class.getName() + "$AuthenticationValidators"; private static final String AUTHENTICATION_METHOD = "SecurityManager.authenticationMethod"; @@ -522,9 +522,9 @@ public static User getSessionUser(HandshakeRequest request) } //noinspection unchecked - Class restrictionRole = (Class) session.getAttribute(RESTRICTION_ROLE_KEY); - if (restrictionRole != null) - sessionUser = new RoleRestrictedUser(sessionUser, restrictionRole); + Set> allowedPermissions = (Set>) session.getAttribute(ALLOWED_PERMISSIONS_KEY); + if (allowedPermissions != null) + sessionUser = new PermissionsRestrictedUser(sessionUser, allowedPermissions); } } @@ -822,8 +822,7 @@ public static HttpSession setAuthenticatedUser(HttpServletRequest request, @Null newSession.setAttribute(PRIMARY_AUTHENTICATION_CONFIGURATION, configuration.getRowId()); newSession.setAttribute(USER_ATTRIBUTES_KEY, response.getUserAttributeMap()); newSession.setAttribute(AUTHENTICATION_PROPERTIES, response.getAuthenticationProperties()); - if (response.getRestrictionRole() != null) - newSession.setAttribute(RESTRICTION_ROLE_KEY, response.getRestrictionRole()); + user.getPermissionsContext().modifySession(newSession); } return newSession; diff --git a/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java b/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java index 2f02beab4c9..5d26eb28d13 100644 --- a/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java +++ b/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java @@ -119,11 +119,9 @@ public boolean isFicamApproved() if (auth != null) { - // API keys are exempt from secondary authentication, Issue 48764 ret = AuthenticationResponse.success(configuration, auth.getUser()).setSuccessDetails(UserManager.UserAuditEvent.API_KEY) - .setRequireSecondary(false) - .setAuthenticationProperties(Map.of(ApiKeyManager.API_KEY_ROW_ID, auth.rowId())) - .setRestrictionRole(auth.restrictionRole()); + .setRequireSecondary(false) // API keys are exempt from secondary authentication, Issue 48764 + .setAuthenticationProperties(Map.of(ApiKeyManager.API_KEY_ROW_ID, auth.rowId())); // Update core.ApiKeys.LastUsed (throttled) API_KEY_LAST_USED_THROTTLE.execute(password); diff --git a/core/src/org/labkey/core/security/SecurityController.java b/core/src/org/labkey/core/security/SecurityController.java index 0058cc966a4..b25265d0c2f 100644 --- a/core/src/org/labkey/core/security/SecurityController.java +++ b/core/src/org/labkey/core/security/SecurityController.java @@ -2374,7 +2374,15 @@ public Object execute(CreateApiKeyForm form, BindException errors) if (!AppProps.getInstance().isAllowApiKeys()) throw new NotFoundException("Creation of API keys is disabled"); - RestrictionRole rr = RESTRICTION_ROLE_MAP.get(form.getRole()); + RestrictionRole rr = null; + + if (form.getRole() != null) + { + rr = RESTRICTION_ROLE_MAP.get(form.getRole()); + if (rr == null) + throw new NotFoundException("Restiction role was not found!"); + } + apiKey = ApiKeyManager.get().createKey(getUser(), form.getDescription(), rr != null ? rr.roleClass() : null); break; case "session": From c4c67ed0243f5db75ca02f38014350475f99cdbc Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 2 Jul 2026 11:52:57 -0700 Subject: [PATCH 09/15] More feedback --- .../labkey/api/security/ApiKeyManager.java | 11 ++++++---- .../api/security/AuthenticationProvider.java | 2 -- .../security/WrappedPermissionsContext.java | 7 ++++++ .../login/DbLoginAuthenticationProvider.java | 22 +++++++++---------- .../core/security/SecurityController.java | 2 +- 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/api/src/org/labkey/api/security/ApiKeyManager.java b/api/src/org/labkey/api/security/ApiKeyManager.java index c3dfc895b77..d6fac052e1e 100644 --- a/api/src/org/labkey/api/security/ApiKeyManager.java +++ b/api/src/org/labkey/api/security/ApiKeyManager.java @@ -51,7 +51,6 @@ import org.labkey.api.util.SystemMaintenance.MaintenanceTask; import org.labkey.api.util.TestContext; import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.NotFoundException; import org.labkey.api.view.UnauthorizedException; import java.time.Instant; @@ -213,7 +212,7 @@ public void updateLastUsed(String apikey) public record ApiKeyAuthentication(int createdBy, int rowId, @Nullable Class restrictionRole) { - public User getUser() + public @Nullable User getUser() { User user = UserManager.getUser(createdBy()); if (restrictionRole != null) @@ -221,9 +220,13 @@ public User getUser() Role role = RoleManager.getRole(restrictionRole); if (role == null) { - throw new NotFoundException("Role " + restrictionRole.getName() + " not found"); + LOG.error("API key for {} specifies a restriction role {} that was not found", user, restrictionRole.getName()); + user = null; + } + else + { + user = new PermissionsRestrictedUser(user, role.getPermissions()); } - user = new PermissionsRestrictedUser(user, role.getPermissions()); } return user; } diff --git a/api/src/org/labkey/api/security/AuthenticationProvider.java b/api/src/org/labkey/api/security/AuthenticationProvider.java index 53953e578d6..b293a5ddf02 100644 --- a/api/src/org/labkey/api/security/AuthenticationProvider.java +++ b/api/src/org/labkey/api/security/AuthenticationProvider.java @@ -30,7 +30,6 @@ import org.labkey.api.security.AuthenticationManager.AuthenticationStatus; import org.labkey.api.security.AuthenticationManager.AuthenticationValidator; import org.labkey.api.security.ValidEmail.InvalidEmailException; -import org.labkey.api.security.roles.Role; import org.labkey.api.settings.StandardStartupPropertyHandler; import org.labkey.api.settings.StartupProperty; import org.labkey.api.settings.StartupPropertyEntry; @@ -307,7 +306,6 @@ class AuthenticationResponse private @Nullable ActionURL _redirectURL = null; private @NotNull Map _userAttributeMap = Collections.emptyMap(); // A case-insensitive map of attribute names and values associated with the user private @NotNull Map _authenticationProperties = Collections.emptyMap(); - private @Nullable Class _restrictionRole; // Limit user's permissions to those of this role private boolean _requireSecondary = true; // Require secondary authentication private boolean _reauth = false; private @Nullable String _successDetails = null; // An optional string describing how successful authentication took place, which will diff --git a/api/src/org/labkey/api/security/WrappedPermissionsContext.java b/api/src/org/labkey/api/security/WrappedPermissionsContext.java index ea37a76f9c0..eeffb655a6e 100644 --- a/api/src/org/labkey/api/security/WrappedPermissionsContext.java +++ b/api/src/org/labkey/api/security/WrappedPermissionsContext.java @@ -15,6 +15,7 @@ */ package org.labkey.api.security; +import jakarta.servlet.http.HttpSession; import org.jetbrains.annotations.Nullable; import org.labkey.api.data.Container; import org.labkey.api.security.impersonation.ImpersonationContextFactory; @@ -97,4 +98,10 @@ public Stream> filterPermissions(Stream Date: Thu, 2 Jul 2026 16:13:01 -0700 Subject: [PATCH 10/15] Unit test for restricted API keys --- .../labkey/api/security/ApiKeyManager.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/api/src/org/labkey/api/security/ApiKeyManager.java b/api/src/org/labkey/api/security/ApiKeyManager.java index d6fac052e1e..b4b6cace88d 100644 --- a/api/src/org/labkey/api/security/ApiKeyManager.java +++ b/api/src/org/labkey/api/security/ApiKeyManager.java @@ -22,6 +22,7 @@ import org.jetbrains.annotations.Nullable; import org.junit.Assert; import org.junit.Test; +import org.labkey.api.data.ContainerManager; import org.labkey.api.data.CoreSchema; import org.labkey.api.data.DbScope; import org.labkey.api.data.DbScope.Transaction; @@ -39,6 +40,13 @@ import org.labkey.api.query.FieldKey; import org.labkey.api.security.UserManager.SessionHandler; import org.labkey.api.security.ValidEmail.InvalidEmailException; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.security.roles.ReaderRole; import org.labkey.api.security.roles.Role; import org.labkey.api.security.roles.RoleManager; import org.labkey.api.settings.AppProps; @@ -62,6 +70,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Stream; import static org.labkey.api.util.IntegerUtils.asInteger; @@ -362,6 +371,53 @@ public void testTransaction() ApiKeyManager.get().deleteKey(apikey); assertNull(ApiKeyManager.get().authenticateFromApiKey(apikey)); } + + private record UserAndKey(User user, String apiKey){} + + @Test + public void testRoleRestrictions() + { + User admin = TestContext.get().getUser(); + UserAndKey readerUAK = createApiKeyAndRetrieveUser(admin, ReaderRole.class); + User reader = readerUAK.user(); + UserAndKey editorUAK = createApiKeyAndRetrieveUser(admin, EditorRole.class); + User editor = editorUAK.user(); + + ContainerManager.getAllChildren(ContainerManager.getRoot(), admin, AdminPermission.class).stream() + .limit(5) + .forEach(child -> { + assertTrue(child.hasPermission(admin, AdminPermission.class)); + assertFalse(child.hasPermission(editor, AdminPermission.class)); + assertFalse(child.hasPermission(reader, AdminPermission.class)); + Stream.of(DeletePermission.class, UpdatePermission.class, InsertPermission.class) + .forEach(perm -> { + assertTrue(child.hasPermission(admin, perm)); + assertTrue(child.hasPermission(editor, perm)); + assertFalse(child.hasPermission(reader, perm)); + }); + assertTrue(child.hasPermission(admin, ReadPermission.class)); + assertTrue(child.hasPermission(editor, ReadPermission.class)); + assertTrue(child.hasPermission(reader, ReadPermission.class)); + }); + + ApiKeyManager.get().deleteKey(readerUAK.apiKey()); + ApiKeyManager.get().deleteKey(editorUAK.apiKey()); + assertNull(ApiKeyManager.get().authenticateFromApiKey(readerUAK.apiKey())); + assertNull(ApiKeyManager.get().authenticateFromApiKey(editorUAK.apiKey())); + } + + private UserAndKey createApiKeyAndRetrieveUser(User user, Class restrictionRole) + { + String apiKey = ApiKeyManager.get().createKey(user, 10, "Created by ApiKeyManager.TestCase", restrictionRole); + ApiKeyAuthentication auth = ApiKeyManager.get().authenticateFromApiKey(apiKey); + assertNotNull(auth); + User restrictedUser = auth.getUser(); + assertNotNull(restrictedUser); + assertEquals(user.getUserId(), restrictedUser.getUserId()); + assertTrue(restrictedUser instanceof PermissionsRestrictedUser); + + return new UserAndKey(restrictedUser, apiKey); + } } public static class ApiKeyMaintenanceTask implements MaintenanceTask From bc0b9c7a33a5dacec65969717854519c0d88cb59 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Fri, 3 Jul 2026 14:34:42 -0700 Subject: [PATCH 11/15] error() -> warn() --- .../labkey/experiment/api/property/DomainPropertyManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiment/src/org/labkey/experiment/api/property/DomainPropertyManager.java b/experiment/src/org/labkey/experiment/api/property/DomainPropertyManager.java index fc85c94eb97..dbc7137c295 100644 --- a/experiment/src/org/labkey/experiment/api/property/DomainPropertyManager.java +++ b/experiment/src/org/labkey/experiment/api/property/DomainPropertyManager.java @@ -173,7 +173,7 @@ public List getConditionalFormats(Container con // upgrade code that cleared out obsolete 'urn:lsid:labkey.com:PropertyValidator:length' rows. if (PropertyService.get().getValidatorKind(pv.getTypeURI()) == null) { - LOG.error("Invalid property validator typeUri: {}", pv.getTypeURI()); + LOG.warn("Invalid property validator typeUri: {}", pv.getTypeURI()); } else { From 3eeb452df77adbcb20d6fe256629eb0c07296338 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Sat, 4 Jul 2026 12:12:12 -0700 Subject: [PATCH 12/15] Latest components --- core/package-lock.json | 8 ++++---- core/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/package-lock.json b/core/package-lock.json index efff98f45bd..3d7270ff783 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.45.3-fb-role-restricted-api-keys.2", + "@labkey/components": "7.45.3-fb-role-restricted-api-keys.3", "@labkey/themes": "1.9.4" }, "devDependencies": { @@ -3789,9 +3789,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.45.3-fb-role-restricted-api-keys.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.45.3-fb-role-restricted-api-keys.2.tgz", - "integrity": "sha512-Y63kErfjETWYIm4aezV4wXsLR1OS3mozgTBl9uztyw8NE5sHtMu5udaaz583rqSmBBXjFizComwt4bgo1Q5h1A==", + "version": "7.45.3-fb-role-restricted-api-keys.3", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.45.3-fb-role-restricted-api-keys.3.tgz", + "integrity": "sha512-s3eLzBdenQvUab6/X7KbX8n/SVQY0UHCGFoT14+cZKV+KDL7eu+ziGNjovTuDjCAPADAEyNmMOIrqfpLlAo9ow==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index b7877f2b704..8b75717d961 100644 --- a/core/package.json +++ b/core/package.json @@ -20,7 +20,7 @@ "lint-branch-fix": "node lint.diff.mjs --currentBranch --fix" }, "dependencies": { - "@labkey/components": "7.45.3-fb-role-restricted-api-keys.2", + "@labkey/components": "7.45.3-fb-role-restricted-api-keys.3", "@labkey/themes": "1.9.4" }, "devDependencies": { From adf9512900257e6e32b675f3b9559172d49d9429 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Mon, 6 Jul 2026 14:33:24 -0700 Subject: [PATCH 13/15] Send AI agents information and recommendations about the user's permission restrictions when invoking whereAmIWhoAmITalkingTo. Update DataAnalysis md files to encourage use of permissions-restricted API keys. --- .../labkey/api/security/PermissionsRestrictedUser.java | 9 +++++++++ api/src/org/labkey/api/security/User.java | 5 +++++ core/src/org/labkey/core/CoreMcp.java | 4 ++-- core/src/org/labkey/core/DataAnalysis_Python.md | 1 + core/src/org/labkey/core/DataAnalysis_R.md | 1 + 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/api/src/org/labkey/api/security/PermissionsRestrictedUser.java b/api/src/org/labkey/api/security/PermissionsRestrictedUser.java index 0fe8820320c..1286fc2da68 100644 --- a/api/src/org/labkey/api/security/PermissionsRestrictedUser.java +++ b/api/src/org/labkey/api/security/PermissionsRestrictedUser.java @@ -39,8 +39,17 @@ public void modifySession(HttpSession session) } } + private final @NotNull Set> _allowedPermissions; + public PermissionsRestrictedUser(User user, @NotNull Set> allowedPermissions) { super(user, new RoleRestrictedPermissionsContext(user.getPermissionsContext(), allowedPermissions)); + _allowedPermissions = allowedPermissions; + } + + @Override + public String getPermissionsRestrictions() + { + return "Current user is restricted to these permissions: " + _allowedPermissions; } } diff --git a/api/src/org/labkey/api/security/User.java b/api/src/org/labkey/api/security/User.java index 394112bebd2..1965ac892b0 100644 --- a/api/src/org/labkey/api/security/User.java +++ b/api/src/org/labkey/api/security/User.java @@ -643,4 +643,9 @@ public String toJSONString() { return String.valueOf(getUserId()); } + + public String getPermissionsRestrictions() + { + return "Current user has unrestricted permissions. AI agents should encourage the use of permissions-restricted API keys instead."; + } } diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index e54bb6118fd..d7b49652b6b 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -67,6 +67,7 @@ String whereAmIWhoAmITalkingTo(ToolContext context) userObj.put("displayName", user.getDisplayName(user)); if (isNotBlank(user.getFirstName())) userObj.put("firstName", user.getFirstName()); + userObj.put("permissionsRestrictions", user.getPermissionsRestrictions()); JSONObject folderObj = new JSONObject(); folderObj.put("name", folder.getName()); @@ -150,7 +151,7 @@ String setContainer(ToolContext context, @ToolParam(description = "Container pat // TODO replace/augment with available feature list @Tool(description = "List the modules installed on this server, this may be useful in inferring the available functionality. For instance, " + - "the presence of the `premium` module implies the availability of premium featues.") + "the presence of the `premium` module implies the availability of premium features.") @RequiresNoPermission public String listModules(ToolContext context) { @@ -221,5 +222,4 @@ public ReadResourceResult getRDataAnalysisGuide() throws IOException ) )); } - } diff --git a/core/src/org/labkey/core/DataAnalysis_Python.md b/core/src/org/labkey/core/DataAnalysis_Python.md index 4a83b50f278..56d82d58230 100644 --- a/core/src/org/labkey/core/DataAnalysis_Python.md +++ b/core/src/org/labkey/core/DataAnalysis_Python.md @@ -3,6 +3,7 @@ This project is for performing data analysis against a LabKey Server instance using AI assistance. **Connection defaults:** The LabKey server URL and API key can be inferred from `.mcp.json` in this directory. The `url` field (minus the `/mcp` path) provides the server endpoint, and the `apikey` header value provides the authentication token. +AI agents should encourage users to create and provide "permissions-restricted API keys" (e.g., limited to a specific role such as Reader or Editor at API key creation time) instead of providing an unrestricted API key. **Do not embed API keys in generated scripts.** Instead, ensure a `.netrc` file (Linux/Mac) or `_netrc` file (Windows) exists in the user's home directory with the server credentials. If the file does not exist, offer to create it using the API key from `.mcp.json`. The format is: diff --git a/core/src/org/labkey/core/DataAnalysis_R.md b/core/src/org/labkey/core/DataAnalysis_R.md index ee6745f33b7..61d72321f99 100644 --- a/core/src/org/labkey/core/DataAnalysis_R.md +++ b/core/src/org/labkey/core/DataAnalysis_R.md @@ -3,6 +3,7 @@ This project is for performing data analysis against a LabKey Server instance using AI assistance. **Connection defaults:** The LabKey server URL and API key can be inferred from `.mcp.json` in this directory. The `url` field (minus the `/mcp` path) provides the server endpoint, and the `apikey` header value provides the authentication token. +AI agents should encourage users to create and provide "permissions-restricted API keys" (e.g., limited to a specific role such as Reader or Editor at API key creation time) instead of providing an unrestricted API key. **Do not embed API keys in generated scripts.** Instead, ensure a `.netrc` file (Linux/Mac) or `_netrc` file (Windows) exists in the user's home directory with the server credentials. If the file does not exist, offer to create it using the API key from `.mcp.json`. The format is: From a5d51d8998b35d16e89fd89f1ab1f4261044816d Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 7 Jul 2026 11:29:29 -0700 Subject: [PATCH 14/15] Show the role display name instead of the fully qualified Java name --- .../labkey/core/query/ApiKeysTableInfo.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/core/src/org/labkey/core/query/ApiKeysTableInfo.java b/core/src/org/labkey/core/query/ApiKeysTableInfo.java index e47848d189f..d2eee7bada3 100644 --- a/core/src/org/labkey/core/query/ApiKeysTableInfo.java +++ b/core/src/org/labkey/core/query/ApiKeysTableInfo.java @@ -18,7 +18,9 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.labkey.api.data.CompareType; +import org.labkey.api.data.DataColumn; import org.labkey.api.data.MutableColumnInfo; +import org.labkey.api.data.RenderContext; import org.labkey.api.data.SimpleFilter; import org.labkey.api.query.FilteredTable; import org.labkey.api.query.QueryUpdateService; @@ -27,6 +29,8 @@ import org.labkey.api.security.permissions.DeletePermission; import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.roles.Role; +import org.labkey.api.security.roles.RoleManager; /** * All users can view and delete from the "filtered to current user" version of this table. CoreQuerySchema shows @@ -44,7 +48,20 @@ public ApiKeysTableInfo(@NotNull CoreQuerySchema schema, boolean filterToCurrent addWrapColumn(getRealTable().getColumn("Expiration")); addWrapColumn(getRealTable().getColumn("LastUsed")); addWrapColumn(getRealTable().getColumn("Description")); - addWrapColumn(getRealTable().getColumn("RestrictionRole")); + // Show the role's display name instead of the fully qualified Java class name that's stored + addWrapColumn(getRealTable().getColumn("RestrictionRole")).setDisplayColumnFactory(colInfo -> new DataColumn(colInfo){ + @Override + public Object getDisplayValue(RenderContext ctx) + { + String value = (String)super.getDisplayValue(ctx); + if (value != null) + { + Role role = RoleManager.getRole(value); + value = role != null ? role.getDisplayName() : ""; + } + return value; + } + }); if (filterToCurrentUser) { From 6fdca343a7cc83a333752026a5c81a3127c52f69 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 7 Jul 2026 11:53:09 -0700 Subject: [PATCH 15/15] @labkey/components 7.45.4 --- core/package-lock.json | 8 ++++---- core/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/package-lock.json b/core/package-lock.json index 3d7270ff783..e0772bb1e67 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.45.3-fb-role-restricted-api-keys.3", + "@labkey/components": "7.45.4", "@labkey/themes": "1.9.4" }, "devDependencies": { @@ -3789,9 +3789,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.45.3-fb-role-restricted-api-keys.3", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.45.3-fb-role-restricted-api-keys.3.tgz", - "integrity": "sha512-s3eLzBdenQvUab6/X7KbX8n/SVQY0UHCGFoT14+cZKV+KDL7eu+ziGNjovTuDjCAPADAEyNmMOIrqfpLlAo9ow==", + "version": "7.45.4", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.45.4.tgz", + "integrity": "sha512-X/iP75JtN4xXcRdH8jdyAV8pwDOfeqYqeJO8kU424eLuYJJhVAy0hI4Vw1erIwrix5y6brylHkmMiBts4RXJ/Q==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index 8b75717d961..7c1b3761eb0 100644 --- a/core/package.json +++ b/core/package.json @@ -20,7 +20,7 @@ "lint-branch-fix": "node lint.diff.mjs --currentBranch --fix" }, "dependencies": { - "@labkey/components": "7.45.3-fb-role-restricted-api-keys.3", + "@labkey/components": "7.45.4", "@labkey/themes": "1.9.4" }, "devDependencies": {