diff --git a/api/src/org/labkey/api/ApiModule.java b/api/src/org/labkey/api/ApiModule.java index 83170e3529d..1a0ebb21c07 100644 --- a/api/src/org/labkey/api/ApiModule.java +++ b/api/src/org/labkey/api/ApiModule.java @@ -189,6 +189,7 @@ import org.labkey.api.view.ViewServlet; import org.labkey.api.view.WebPartFactory; import org.labkey.api.webdav.WebdavResolverImpl; +import org.labkey.api.wiki.WikiRendererType; import org.labkey.api.writer.ContainerUser; import org.labkey.filters.ContentSecurityPolicyFilter; @@ -562,6 +563,7 @@ public void registerServlets(ServletContext servletCtx) UserManager.TestCase.class, ViewCategoryManager.TestCase.class, WebdavResolverImpl.TestCase.class, + WikiRendererType.TestCase.class, WorkbookContainerType.TestCase.class, WriteableLookAndFeelProperties.TestCase.class ); diff --git a/api/src/org/labkey/api/admin/AdminBean.java b/api/src/org/labkey/api/admin/AdminBean.java index f61e1d45f07..4c655762208 100644 --- a/api/src/org/labkey/api/admin/AdminBean.java +++ b/api/src/org/labkey/api/admin/AdminBean.java @@ -24,7 +24,6 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.data.CoreSchema; import org.labkey.api.data.DbScope; -import org.labkey.api.module.Module; import org.labkey.api.module.ModuleLoader; import org.labkey.api.security.UserManager; import org.labkey.api.settings.AppProps; @@ -43,7 +42,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.TreeMap; @@ -82,7 +80,6 @@ public static class RecentUser public static final String sessionTimeout = Formats.commaf0.format(ModuleLoader.getServletContext().getSessionTimeout()); public static final String buildTime = ModuleLoader.getInstance().getCoreModule().getBuildTime(); public static final String serverStartupTime = DateUtil.formatDateTime(ContainerManager.getRoot()); - public static final List modules; public static String asserts = "disabled"; @@ -116,9 +113,6 @@ public static class RecentUser //noinspection ConstantConditions,AssertWithSideEffects assert null != (asserts = "enabled"); - - modules = new ArrayList<>(ModuleLoader.getInstance().getModules()); - modules.sort(Comparator.comparing(Module::getName, String.CASE_INSENSITIVE_ORDER)); } private static @Nullable String getValue(Field field) diff --git a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java index 1b74eec0fa7..fa53d3cf3c9 100644 --- a/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/BasePostgreSqlDialect.java @@ -924,6 +924,19 @@ public boolean supportsNativeGreatestAndLeast() return true; } + @Override + public boolean supportsIsNumeric() + { + return true; + } + + @Override + public SQLFragment isNumericExpr(SQLFragment expression) + { + return new SQLFragment("(CASE WHEN CAST((").append(expression) + .append(") AS TEXT) ~ '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$' THEN 1 ELSE 0 END)"); + } + private class PostgreSqlColumnMetaDataReader extends ColumnMetaDataReader { private final TableInfo _table; diff --git a/api/src/org/labkey/api/data/dialect/SqlDialect.java b/api/src/org/labkey/api/data/dialect/SqlDialect.java index 06f31efb50b..946edfa95b2 100644 --- a/api/src/org/labkey/api/data/dialect/SqlDialect.java +++ b/api/src/org/labkey/api/data/dialect/SqlDialect.java @@ -863,15 +863,25 @@ public SQLFragment getNumericCast(SQLFragment expression) * @param arguments Arguments passed from the LK SQL * @return the dialect equivalent SQLFragrment */ - public SQLFragment getGreatestAndLeastSQL(String method, SQLFragment... arguments) - { - throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); - } - - public void handleCreateDatabaseException(SQLException e) throws ServletException - { - throw(new ServletException("Can't create database", e)); - } + public SQLFragment getGreatestAndLeastSQL(String method, SQLFragment... arguments) + { + throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); + } + + public boolean supportsIsNumeric() + { + return false; + } + + public SQLFragment isNumericExpr(SQLFragment expression) + { + throw new UnsupportedOperationException(getClass().getSimpleName() + " does not implement"); + } + + public void handleCreateDatabaseException(SQLException e) throws ServletException + { + throw(new ServletException("Can't create database", e)); + } /** * Wrap one or more INSERT statements to allow explicit specification diff --git a/api/src/org/labkey/api/mcp/AbstractAgentAction.java b/api/src/org/labkey/api/mcp/AbstractAgentAction.java index 0affd4c5517..a8bb158aab1 100644 --- a/api/src/org/labkey/api/mcp/AbstractAgentAction.java +++ b/api/src/org/labkey/api/mcp/AbstractAgentAction.java @@ -85,9 +85,18 @@ protected String handleEscape(String prompt) @Override public void validateForm(F form, Errors errors) { + if (!McpService.get().isAIFeaturesEnabled()) + { + errors.reject(ERROR_GENERIC, "Agent actions are not available."); + return; + } + String prompt = form.getPrompt(); if (prompt != null && prompt.length() > MAX_PROMPT_CHAR_LENGTH) + { errors.rejectValue("prompt", ERROR_GENERIC, "Prompt cannot exceed " + MAX_PROMPT_CHAR_LENGTH + " characters."); + return; + } // Only honor a client-supplied conversationId if this agent previously issued this session that // id. Otherwise, generate a fresh one. This prevents a same-session caller from splicing into diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 3841c9ae145..06afb51e63a 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -36,6 +36,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.function.Supplier; /// @@ -94,8 +95,11 @@ /// public interface McpService extends ToolCallbackProvider { + String VECTOR_SCHEMA = "vector_indexes"; // for pgvector extension, see core-26.005-26.006.sql + Logger LOG = LogHelper.getLogger(McpService.class, "MCP registration exceptions"); String ENABLE_MCP_SERVER_FLAG = "enableMcpServer"; + String ENABLE_AI_FEATURES = "enableAIFeatures"; // Interface for MCP classes that we will "ingest" using Spring annotations. Provides a few helper methods. interface McpImpl @@ -117,9 +121,6 @@ default User getUser(ToolContext toolContext) // Every MCP resource should call this on every invocation default void incrementResourceRequestCount(String resource) { - if (!get().isEnabled()) - throw new RuntimeException("The MCP server is not enabled for external requests. Consider toggling the optional feature flag."); - get().incrementResourceRequestCount(resource); } } @@ -142,8 +143,19 @@ default boolean isEnabled() return OptionalFeatureService.get().isFeatureEnabled(ENABLE_MCP_SERVER_FLAG); } + default boolean isAIFeaturesEnabled() + { + return OptionalFeatureService.get().isFeatureEnabled(ENABLE_AI_FEATURES); + } + boolean isReady(); + // Convenience for in-product AI features: the AI feature flag is on AND the service has started. + default boolean isAIFeaturesReady() + { + return isAIFeaturesEnabled() && isReady(); + } + // Register MCPs in Module.startup() default void register(McpImpl mcp) { @@ -187,6 +199,8 @@ default ChatClient getChat(HttpSession session, String conversationName, Supplie record MessageResponse(String contentType, String text, HtmlString html) {} + record VectorDocument(String id, String text, Map metadata) {} + /** get a consolidated response (good for many text-oriented agents/use-cases) */ MessageResponse sendMessage(ChatClient chat, String message); @@ -201,4 +215,21 @@ default List sendMessageEx(ChatClient chat, String message) * CONSIDER: Is it possible to implement VectorStoreRetriever wrapper for SearchService??? */ VectorStore getVectorStore(); + + /** Returns true if the vector store exists and contains at least one document. */ + boolean isVectorStorePopulated(@NotNull VectorStore vs); + + /** + * Adds documents to the vector store, automatically splitting any document whose token + * count exceeds the embedding model's input limit. Prefer this over + * {@code getVectorStore().add(...)} for indexing — it prevents the + * {@code IllegalArgumentException} that {@code TokenCountBatchingStrategy} throws on + * oversized inputs. + */ + void addDocuments(List documents); + + void saveVectorStore(); + + /** Drop and recreate the vector store table. Use when the embedding model has changed and dimensions no longer match. */ + void resetVectorStore(); } diff --git a/api/src/org/labkey/api/mcp/NoopMcpService.java b/api/src/org/labkey/api/mcp/NoopMcpService.java index e33eb422a4c..1923c770ccd 100644 --- a/api/src/org/labkey/api/mcp/NoopMcpService.java +++ b/api/src/org/labkey/api/mcp/NoopMcpService.java @@ -42,6 +42,12 @@ public boolean isEnabled() return false; } + @Override + public boolean isAIFeaturesEnabled() + { + return false; + } + @Override public boolean isReady() { @@ -101,4 +107,25 @@ public VectorStore getVectorStore() { return null; } + + @Override + public boolean isVectorStorePopulated(@NotNull VectorStore vs) + { + return false; + } + + @Override + public void addDocuments(List documents) + { + } + + @Override + public void saveVectorStore() + { + } + + @Override + public void resetVectorStore() + { + } } diff --git a/api/src/org/labkey/api/moduleeditor/api/ModuleEditorService.java b/api/src/org/labkey/api/moduleeditor/api/ModuleEditorService.java index cdeb61c0d6f..21b01c10120 100644 --- a/api/src/org/labkey/api/moduleeditor/api/ModuleEditorService.java +++ b/api/src/org/labkey/api/moduleeditor/api/ModuleEditorService.java @@ -81,4 +81,11 @@ default File getFileForModuleResource(Module module, Path path) return null; return FileUtil.appendPath(resources, path); } + + // used by ModuleResourceProvider@AllModuleResourcesRoot() (_webdav/@modules) which wants to soo + // the whole module source directory (e.g. when using SourcePath) + default File getModuleRoot(Module module, @Nullable List messages) + { + return null; + } } \ No newline at end of file diff --git a/api/src/org/labkey/api/security/ApiKeyManager.java b/api/src/org/labkey/api/security/ApiKeyManager.java index 69684001985..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,15 @@ 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; import org.labkey.api.settings.LenientStartupPropertyHandler; import org.labkey.api.settings.StartupProperty; @@ -60,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; @@ -85,9 +96,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 restrictionRole) { - return createKey(user, AppProps.getInstance().getApiKeyExpirationSeconds(), description); + return createKey(user, AppProps.getInstance().getApiKeyExpirationSeconds(), description, restrictionRole); } /** @@ -97,6 +108,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 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 restrictionRole) { if (user.isGuest()) throw new IllegalStateException("Can't create an API key for a guest"); @@ -120,6 +143,9 @@ private ApiKeyManager() if (description != null) map.put("Description", StringUtils.abbreviate(description.trim(), 256)); + if (restrictionRole != null) + map.put("RestrictionRole", restrictionRole.getName()); + try (Transaction t = CoreSchema.getInstance().getScope().beginTransaction(TRANSACTION_KIND)) { Table.insert(user, CoreSchema.getInstance().getTableAPIKeys(), map); @@ -183,17 +209,35 @@ 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 restrictionRole) { - public User getUser() + public @Nullable User getUser() { - return UserManager.getUser(createdBy()); + User user = UserManager.getUser(createdBy()); + if (restrictionRole != null) + { + Role role = RoleManager.getRole(restrictionRole); + if (role == null) + { + 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()); + } + } + return user; } } @@ -212,7 +256,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(); } @@ -327,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 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/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); + } + } + + 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/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..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; /** @@ -517,8 +518,13 @@ public static User getSessionUser(HandshakeRequest request) if (null != factory) { - sessionUser.setImpersonationContext(factory.getImpersonationContext()); + sessionUser.setPermissionsContext(factory.getImpersonationContext()); } + + //noinspection unchecked + Set> allowedPermissions = (Set>) session.getAttribute(ALLOWED_PERMISSIONS_KEY); + if (allowedPermissions != null) + sessionUser = new PermissionsRestrictedUser(sessionUser, allowedPermissions); } } @@ -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,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()); + user.getPermissionsContext().modifySession(newSession); } return newSession; @@ -1121,7 +1128,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 +3350,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..1965ac892b0 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; } @@ -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/api/src/org/labkey/api/security/WrappedPermissionsContext.java b/api/src/org/labkey/api/security/WrappedPermissionsContext.java index a33506a12af..eeffb655a6e 100644 --- a/api/src/org/labkey/api/security/WrappedPermissionsContext.java +++ b/api/src/org/labkey/api/security/WrappedPermissionsContext.java @@ -15,7 +15,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.security.impersonation.ImpersonationContextFactory; @@ -24,26 +24,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 +78,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 @@ -106,4 +98,10 @@ public Stream> filterPermissions(Stream attachmentFiles, @Nullable List deleteAttachmentNames); AttachmentParentType getAttachmentType(); + + /** + * Loads all wikis from the given container into the MCP vector store. + * + *

Each {@link org.labkey.api.mcp.McpService.VectorDocument} is assigned an ID of the form + * {@code "/"}, where both components are GUIDs + * (as returned by {@link Container#getId()} and the wiki's own entity ID). + * Tools that consume vector store results (e.g. {@code listDocuments}, + * {@code retrieveDocument}) must use this same format when constructing or + * interpreting document IDs.

+ * + * @return the number of documents added + */ + int populateVectorStore(Container container); } diff --git a/assay/api-src/org/labkey/api/assay/AssayResultDomainKind.java b/assay/api-src/org/labkey/api/assay/AssayResultDomainKind.java index 38d42d23233..af87f14db13 100644 --- a/assay/api-src/org/labkey/api/assay/AssayResultDomainKind.java +++ b/assay/api-src/org/labkey/api/assay/AssayResultDomainKind.java @@ -100,15 +100,6 @@ public Set getPropertyIndices(Domain domain) return PageFlowUtil.set(new PropertyStorageSpec.Index(false, AbstractTsvAssayProvider.DATA_ID_COLUMN_NAME)); } - @Override - public Set getPropertyForeignKeys(Container container) - { - return new HashSet<>(Arrays.asList( - new PropertyStorageSpec.ForeignKey(SpecialColumn.CreatedBy.name(), "core", "users", "userid", null, false), - new PropertyStorageSpec.ForeignKey(SpecialColumn.ModifiedBy.name(), "core", "users", "userid", null, false) - )); - } - @Override public DbScope getScope() { diff --git a/core/module.properties b/core/module.properties index d4c7dec2745..0731d77b149 100644 --- a/core/module.properties +++ b/core/module.properties @@ -1,13 +1,13 @@ -Name: Core -ModuleClass: org.labkey.core.CoreModule -SchemaVersion: 26.005 -Label: Administration and Essential Services -Description: The Core module provides central services such as login, \ - security, administration, folder management, user management, \ - module upgrade, file attachments, analytics, and portal page management. -Organization: LabKey -OrganizationURL: https://www.labkey.com/ -License: Apache 2.0 -LicenseURL: http://www.apache.org/licenses/LICENSE-2.0 -SupportedDatabases: mssql, pgsql -ManageVersion: true +Name: Core +ModuleClass: org.labkey.core.CoreModule +SchemaVersion: 26.007 +Label: Administration and Essential Services +Description: The Core module provides central services such as login, \ + security, administration, folder management, user management, \ + module upgrade, file attachments, analytics, and portal page management. +Organization: LabKey +OrganizationURL: https://www.labkey.com/ +License: Apache 2.0 +LicenseURL: http://www.apache.org/licenses/LICENSE-2.0 +SupportedDatabases: mssql, pgsql +ManageVersion: true diff --git a/core/package-lock.json b/core/package-lock.json index 3bab6585671..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.2", + "@labkey/components": "7.45.4", "@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.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 48eae14a889..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.2", + "@labkey/components": "7.45.4", "@labkey/themes": "1.9.4" }, "devDependencies": { 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/resources/schemas/dbscripts/postgresql/core-26.005-26.006.sql b/core/resources/schemas/dbscripts/postgresql/core-26.005-26.006.sql new file mode 100644 index 00000000000..40ae6f63620 --- /dev/null +++ b/core/resources/schemas/dbscripts/postgresql/core-26.005-26.006.sql @@ -0,0 +1 @@ +CREATE SCHEMA IF NOT EXISTS "vector_indexes"; \ No newline at end of file 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 diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index d85fa45dca9..d7b49652b6b 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -19,11 +19,13 @@ import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; +import org.json.JSONArray; import org.json.JSONObject; import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; import org.labkey.api.mcp.McpService; +import org.labkey.api.module.ModuleLoader; import org.labkey.api.security.RequiresNoPermission; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.User; @@ -65,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()); @@ -145,11 +148,32 @@ String setContainer(ToolContext context, @ToolParam(description = "Container pat return message; } + + // 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 features.") + @RequiresNoPermission + public String listModules(ToolContext context) + { + JSONArray modules = new JSONArray(); + ModuleLoader.getInstance().getModules().stream() + .map(module -> { + JSONObject obj = new JSONObject(); + obj.put("name", module.getName()); + if (StringUtils.isNotEmpty(module.getLabel())) + obj.put("label", module.getLabel()); + return obj; + }) + .forEach(modules::put); + return new JSONObject(Map.of("modules",modules)).toString(); + } + + @McpResource( uri = "resource://org/labkey/core/FileBasedModules.md", mimeType = "application/markdown", name = "File-Based Module Development Guide", - description = "Provide documentation for developing LabKey file-based modules") + description = "Required reading before building file-based modules. Covers module.properties, directory structure, web parts, SQL queries, reports, and deployment.") public ReadResourceResult getFileBasedModuleDevelopmentGuide() throws IOException { incrementResourceRequestCount("File-Based Modules"); @@ -167,7 +191,7 @@ public ReadResourceResult getFileBasedModuleDevelopmentGuide() throws IOExceptio uri = "resource://org/labkey/core/DataAnalysis_Python.md", mimeType = "application/markdown", name = "Python Data Analysis Development Guide", - description = "Provide documentation for developers using Python to analyze LabKey data") + description = "Required reading before writing Python scripts. Covers APIWrapper setup, .netrc auth, select_rows, execute_sql, QueryFilter, and pandas workflows.") public ReadResourceResult getPythonDataAnalysisGuide() throws IOException { incrementResourceRequestCount("Python Data Analysis"); @@ -185,7 +209,7 @@ public ReadResourceResult getPythonDataAnalysisGuide() throws IOException uri = "resource://org/labkey/core/DataAnalysis_R.md", mimeType = "application/markdown", name = "R Data Analysis Development Guide", - description = "Provide documentation for developers using R to analyze LabKey data") + description = "Required reading before writing R scripts. Covers Rlabkey setup, .netrc auth, labkey.selectRows, labkey.executeSql, makeFilter, and data frame workflows.") public ReadResourceResult getRDataAnalysisGuide() throws IOException { incrementResourceRequestCount("R Data Analysis"); @@ -198,5 +222,4 @@ public ReadResourceResult getRDataAnalysisGuide() throws IOException ) )); } - } diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index 8d87668be99..657a3dd5640 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -368,6 +368,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.labkey.api.mcp.McpService.VECTOR_SCHEMA; import static org.labkey.api.settings.StashedStartupProperties.homeProjectFolderType; import static org.labkey.api.settings.StashedStartupProperties.homeProjectResetPermissions; import static org.labkey.api.settings.StashedStartupProperties.homeProjectWebparts; @@ -1556,7 +1557,8 @@ public Collection getSchemaNames() CoreSchema.getInstance().getSchemaName(), // core PropertySchema.getInstance().getSchemaName(), // prop TestSchema.getInstance().getSchemaName(), // test - DbSchema.TEMP_SCHEMA_NAME // temp + DbSchema.TEMP_SCHEMA_NAME, // temp + VECTOR_SCHEMA // used by pgvector - see core-26.005-26.006.sql ); } 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: diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index fb127f42277..bdfaa7b98c9 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -9429,7 +9429,7 @@ protected void renderView(Object model, HtmlWriter out) if (null != module.getZippedPath()) p = module.getZippedPath().toPath(); if (isDevMode && ModuleEditorService.get().canEditSourceModule(module)) - if (!module.getExplodedPath().getPath().equals(module.getSourcePath())) + if (!isBlank(module.getSourcePath()) && !module.getExplodedPath().getPath().equals(module.getSourcePath())) p = Paths.get(module.getSourcePath()); fullPathToModule = p.toString(); shortPathToModule = fullPathToModule; diff --git a/core/src/org/labkey/core/admin/admin.jsp b/core/src/org/labkey/core/admin/admin.jsp index 15b4821c403..befbdb1837e 100644 --- a/core/src/org/labkey/core/admin/admin.jsp +++ b/core/src/org/labkey/core/admin/admin.jsp @@ -23,6 +23,7 @@ <%@ page import="org.labkey.api.files.FileContentService" %> <%@ page import="org.labkey.api.module.DefaultModule" %> <%@ page import="org.labkey.api.module.Module" %> +<%@ page import="org.labkey.api.module.ModuleLoader"%> <%@ page import="org.labkey.api.moduleeditor.api.ModuleEditorService" %> <%@ page import="org.labkey.api.settings.AdminConsole" %> <%@ page import="org.labkey.api.settings.AdminConsole.AdminLink" %> @@ -36,7 +37,9 @@ <%@ page import="java.time.Duration" %> <%@ page import="java.time.LocalDateTime" %> <%@ page import="java.time.format.DateTimeFormatter" %> +<%@ page import="java.util.ArrayList" %> <%@ page import="java.util.Collection" %> +<%@ page import="java.util.Comparator" %> <%@ page import="java.util.Map" %> <%@ page import="java.util.TreeMap" %> <%@ page import="org.apache.commons.lang3.Strings" %> @@ -152,7 +155,9 @@ <%=link("Module Details", AdminController.ModulesAction.class)%>

<% - for (Module module : AdminBean.modules) + ArrayList modules = new ArrayList<>(ModuleLoader.getInstance().getModules()); + modules.sort(Comparator.comparing(Module::getName, String.CASE_INSENSITIVE_ORDER)); + for (Module module : modules) { String guid = makeId("m_"); String toggleScript = "return LABKEY.Utils.toggleLink(document.getElementById(" + q(guid) + "), false);"; diff --git a/core/src/org/labkey/core/admin/sql/SqlScriptController.java b/core/src/org/labkey/core/admin/sql/SqlScriptController.java index 575d44a361a..42305e175b7 100644 --- a/core/src/org/labkey/core/admin/sql/SqlScriptController.java +++ b/core/src/org/labkey/core/admin/sql/SqlScriptController.java @@ -1122,7 +1122,7 @@ protected void renderScript(SqlScript script, PrintWriter out) protected void renderButtons(SqlScript script, PrintWriter out) { out.println(PageFlowUtil.button("Reorder Script").href(getScriptURL(ReorderScriptAction.class, script))); - if (McpService.get().isReady()) + if (McpService.get().isAIFeaturesReady()) { out.println( PageFlowUtil.button("Clean Up Script") @@ -1259,7 +1259,7 @@ protected abstract class BaseAIScriptAction extends ScriptAction protected ModelAndView getScriptView(SqlScript script) { McpService mcpService = McpService.get(); - if (mcpService.isReady()) + if (mcpService.isAIFeaturesReady()) { String prompt = getPrompt(DbScope.getLabKeyScope().getSqlDialect(), script); HttpSession session = getViewContext().getSession(); diff --git a/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java b/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java index f9f9283cf48..0208d853245 100644 --- a/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java +++ b/core/src/org/labkey/core/login/DbLoginAuthenticationProvider.java @@ -115,24 +115,21 @@ public boolean isFicamApproved() if (API_KEY.equals(id)) { ApiKeyAuthentication auth = ApiKeyManager.get().authenticateFromApiKey(password); - final AuthenticationResponse ret; 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())); - - // Update core.ApiKeys.LastUsed (throttled) - API_KEY_LAST_USED_THROTTLE.execute(password); - } - else - { - ret = AuthenticationResponse.failure(configuration, FailureReason.badApiKey); + User user = auth.getUser(); + if (user != null) + { + // Update core.ApiKeys.LastUsed (throttled) + API_KEY_LAST_USED_THROTTLE.execute(password); + return AuthenticationResponse.success(configuration, user).setSuccessDetails(UserManager.UserAuditEvent.API_KEY) + .setRequireSecondary(false) // API keys are exempt from secondary authentication, Issue 48764 + .setAuthenticationProperties(Map.of(ApiKeyManager.API_KEY_ROW_ID, auth.rowId())); + } } - return ret; + return AuthenticationResponse.failure(configuration, FailureReason.badApiKey); } else { diff --git a/core/src/org/labkey/core/query/ApiKeysTableInfo.java b/core/src/org/labkey/core/query/ApiKeysTableInfo.java index bbdc93a622e..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,6 +48,20 @@ public ApiKeysTableInfo(@NotNull CoreQuerySchema schema, boolean filterToCurrent addWrapColumn(getRealTable().getColumn("Expiration")); addWrapColumn(getRealTable().getColumn("LastUsed")); addWrapColumn(getRealTable().getColumn("Description")); + // 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) { diff --git a/core/src/org/labkey/core/security/SecurityController.java b/core/src/org/labkey/core/security/SecurityController.java index 023e4c30eab..da5d42b50d0 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 RestrictionRole(Class roleClass) + { + String displayName() + { + return RoleManager.getRole(roleClass).getDisplayName(); + } + } + + private static final Map RESTRICTION_ROLE_MAP = Stream.of( + ReaderRole.class, + AuthorRole.class, + EditorWithoutDeleteRole.class, + EditorRole.class + ).collect(LabKeyCollectors.toLinkedMap(Class::getName, RestrictionRole::new)); + + @RequiresLogin + public static class GetApiKeyRolesAction extends ReadOnlyApiAction + { + @Override + public Object execute(Object o, BindException errors) throws Exception + { + return RESTRICTION_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,16 @@ 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()); + RestrictionRole rr = null; + + if (form.getRole() != null) + { + rr = RESTRICTION_ROLE_MAP.get(form.getRole()); + if (rr == null) + throw new NotFoundException("Restriction role was not found."); + } + + apiKey = ApiKeyManager.get().createKey(getUser(), form.getDescription(), rr != null ? rr.roleClass() : null); break; case "session": if (!AppProps.getInstance().isAllowSessionKeys()) @@ -2365,29 +2418,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() ); } diff --git a/devtools/src/org/labkey/devtools/TestController.java b/devtools/src/org/labkey/devtools/TestController.java index 5de66de2919..d4b39982f9b 100644 --- a/devtools/src/org/labkey/devtools/TestController.java +++ b/devtools/src/org/labkey/devtools/TestController.java @@ -18,13 +18,11 @@ import jakarta.servlet.http.HttpServletResponse; import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.junit.Assert; import org.junit.Test; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ConfirmAction; import org.labkey.api.action.FormArrayList; import org.labkey.api.action.FormViewAction; import org.labkey.api.action.JsonInputLimit; @@ -36,7 +34,6 @@ import org.labkey.api.action.SimpleResponse; import org.labkey.api.action.SimpleViewAction; import org.labkey.api.action.SpringActionController; -import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; import org.labkey.api.data.DbScope; import org.labkey.api.data.SQLFragment; @@ -63,9 +60,7 @@ import org.labkey.api.util.ConfigurationException; import org.labkey.api.util.DOM; import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; -import org.labkey.api.util.HtmlStringBuilder; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.TestContext; import org.labkey.api.util.URLHelper; @@ -80,15 +75,10 @@ import org.labkey.api.view.ViewServlet; import org.labkey.api.view.template.ClientDependency; import org.labkey.api.view.template.PageConfig; -import org.labkey.api.wiki.WikiService; -import org.springframework.ai.document.Document; -import org.springframework.ai.vectorstore.SimpleVectorStore; -import org.springframework.ai.vectorstore.VectorStore; import org.springframework.dao.PessimisticLockingFailureException; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.validation.BindException; import org.springframework.validation.Errors; -import org.springframework.validation.ObjectError; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.mvc.Controller; @@ -98,9 +88,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Gatherers; import static org.labkey.api.util.DOM.Attribute.name; import static org.labkey.api.util.DOM.Attribute.src; @@ -1298,7 +1285,7 @@ public static class ChatAction extends SimpleViewAction @Override public ModelAndView getView(Object o, BindException errors) { - if (null == McpService.get() || !McpService.get().isReady()) + if (null == McpService.get() || !McpService.get().isAIFeaturesReady()) return HtmlView.of("Service is not ready yet."); getPageConfig().setTemplate(PageConfig.Template.Dialog); return new JspView<>("/org/labkey/devtools/view/chat.jsp"); @@ -1328,88 +1315,6 @@ protected String getServicePrompt() } } - - @RequiresLogin - public static class PopulateVectorStoreAction extends ConfirmAction - { - AtomicInteger count = new AtomicInteger(); - - @Override - public ModelAndView getConfirmView(Object o, BindException errors) - { - var db = FileUtil.getTempDirectoryFileLike().resolveChild("VectorStore.database"); - HtmlStringBuilder message = HtmlStringBuilder.of(); - message.append("This will add the contents of /Documention wikis to the vector store.").append(HtmlString.BR); - message.append("This may take a few minutes."); - if (db.exists()) - message.unsafeAppend("

").append("I see a vector store file already exists. Just FYI."); - return new HtmlView(message); - } - - @Override - public void validateCommand(Object o, Errors errors) - { - } - - @Override - public @NotNull URLHelper getSuccessURL(Object o) - { - return null; - } - - - // not usually used but some actions return views that close the current window etc... - @Override - public ModelAndView getSuccessView(Object form) - { - return HtmlView.of(count.get() + " documents added to vector store"); - } - - @Override - public boolean handlePost(Object o, BindException errors) - { - Container documentsContainer = ContainerManager.getForPath("/Documentation"); - if (null == documentsContainer) - throw new NotFoundException(); - VectorStore vs = McpService.get().getVectorStore(); - if (null == vs) - throw new NotFoundException("/Documentation project was not found"); - - ActionURL wikiBase = new ActionURL("wiki","page",documentsContainer); - - WikiService service = Objects.requireNonNull(WikiService.get()); - List all = service.getNames(documentsContainer); - all.stream() - .map(name -> service.getRenderedWiki(documentsContainer, name)) - .filter(Objects::nonNull) - .map(wiki -> - { - count.incrementAndGet(); - var metadata = Map.of( - "Content-Type", "text/html", - "filename", wiki.name() + ".html", - "title", (Object)wiki.title(), - "source", wikiBase.clone().addParameter("name",wiki.name()).getURIString() - ); - return new Document(wiki.entityId(), wiki.html().toString(), metadata); - }) - .gather(Gatherers.windowFixed(50)) - .forEach(vs); - - var db = FileUtil.getTempDirectoryFileLike().resolveChild("VectorStore.database"); - try - { - ((SimpleVectorStore)vs).save(db.toNioPathForRead().toFile()); - return true; - } - catch (Exception x) - { - errors.addError(new ObjectError("form", "error saving vectordb: " + x.getMessage())); - return false; - } - } - } - @RequiresPermission(UpdatePermission.class) public static class ExecuteMutatingSqlGetAction extends SimpleViewAction { diff --git a/devtools/src/org/labkey/devtools/view/chat.jsp b/devtools/src/org/labkey/devtools/view/chat.jsp index 2799776390d..19b8defd8e5 100644 --- a/devtools/src/org/labkey/devtools/view/chat.jsp +++ b/devtools/src/org/labkey/devtools/view/chat.jsp @@ -15,10 +15,6 @@ * limitations under the License. */ %> -<%@ page import="org.labkey.api.util.DOM" %> -<%@ page import="java.util.stream.Stream" %> -<%@ page import="static org.labkey.api.util.DOM.*" %> -<%@ page import="static org.labkey.api.util.DOM.Attribute.*" %> <%@ page extends="org.labkey.api.jsp.JspBase" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %>