feat(ext-data): add unified Data gateway; mcp: derive roles claim from OIDC #7
+9
-1
@@ -1,6 +1,7 @@
|
|||||||
package dev.relism.flash.ext.data;
|
package dev.relism.flash.ext.data;
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.Tx;
|
import dev.relism.flash.ext.data.core.Tx;
|
||||||
|
import dev.relism.flash.ext.data.core.Data;
|
||||||
import dev.relism.flash.ext.data.core.TxDefinition;
|
import dev.relism.flash.ext.data.core.TxDefinition;
|
||||||
import dev.relism.flash.ext.data.core.TxManager;
|
import dev.relism.flash.ext.data.core.TxManager;
|
||||||
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
||||||
@@ -19,16 +20,23 @@ public final class DataExtension implements FlashExtension {
|
|||||||
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
|
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
|
||||||
private final TxManager txManager;
|
private final TxManager txManager;
|
||||||
private final Tx tx;
|
private final Tx tx;
|
||||||
|
private final Data data;
|
||||||
|
|
||||||
public DataExtension(TxManager txManager) {
|
public DataExtension(TxManager txManager) {
|
||||||
|
this(txManager, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataExtension(TxManager txManager, Data data) {
|
||||||
this.txManager = Objects.requireNonNull(txManager);
|
this.txManager = Objects.requireNonNull(txManager);
|
||||||
this.tx = new Tx(txManager);
|
this.data = data;
|
||||||
|
this.tx = data != null ? data.tx() : new Tx(txManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
ctx.provide(Tx.class, tx);
|
ctx.provide(Tx.class, tx);
|
||||||
ctx.provide(TxManager.class, txManager);
|
ctx.provide(TxManager.class, txManager);
|
||||||
|
if (data != null) ctx.provide(Data.class, data);
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
||||||
if (ann == null) {
|
if (ann == null) {
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package dev.relism.flash.ext.data.core;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-facing data gateway. Repositories are created once per entity type and are safe to
|
||||||
|
* share: transaction/session state stays in {@link Tx}, never in the repository instance.
|
||||||
|
*/
|
||||||
|
public final class Data {
|
||||||
|
private final Tx tx;
|
||||||
|
private final RepositoryFactory repositories;
|
||||||
|
private final ConcurrentHashMap<Class<?>, Repository<?, ?>> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public Data(Tx tx, RepositoryFactory repositories) {
|
||||||
|
this.tx = Objects.requireNonNull(tx, "tx");
|
||||||
|
this.repositories = Objects.requireNonNull(repositories, "repositories");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Tx tx() { return tx; }
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T, ID extends Serializable> Repository<T, ID> repository(Class<T> type) {
|
||||||
|
Objects.requireNonNull(type, "type");
|
||||||
|
return (Repository<T, ID>) cache.computeIfAbsent(type, key -> repositories.create(tx, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void read(Tx.TxRunnable work) { tx.run(tx.readOnly(), work); }
|
||||||
|
public <T> T read(Tx.TxCallable<T> work) { return tx.call(tx.readOnly(), work); }
|
||||||
|
public void write(Tx.TxRunnable work) { tx.run(work); }
|
||||||
|
public <T> T write(Tx.TxCallable<T> work) { return tx.call(work); }
|
||||||
|
/** Transitional low-level access for infrastructure that needs an explicit definition. */
|
||||||
|
public void run(Tx.TxRunnable work) { tx.run(work); }
|
||||||
|
public <T> T call(Tx.TxCallable<T> work) { return tx.call(work); }
|
||||||
|
public <T> T call(TxDefinition definition, Tx.TxCallable<T> work) { return tx.call(definition, work); }
|
||||||
|
public TxDefinition readOnly() { return tx.readOnly(); }
|
||||||
|
|
||||||
|
/** Registers work that runs only after the enclosing write transaction commits. */
|
||||||
|
public void afterCommit(Runnable work) {
|
||||||
|
if (!tx.isActive()) throw new IllegalStateException("afterCommit requires an active transaction");
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override public void afterCommit() { work.run(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.relism.flash.ext.data.core;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/** Creates the backend-specific, stateless repository for one entity type. */
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface RepositoryFactory {
|
||||||
|
<T, ID extends Serializable> Repository<T, ID> create(Tx tx, Class<T> type);
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package dev.relism.flash.ext.data.hibernate;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.data.core.Data;
|
||||||
|
import dev.relism.flash.ext.data.core.Repository;
|
||||||
|
import dev.relism.flash.ext.data.core.RepositoryFactory;
|
||||||
|
import dev.relism.flash.ext.data.core.Tx;
|
||||||
|
import dev.relism.flash.ext.data.core.TxManager;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/** Hibernate-backed {@link Data} factory. */
|
||||||
|
public final class HibernateData {
|
||||||
|
private HibernateData() {}
|
||||||
|
|
||||||
|
private static final RepositoryFactory REPOSITORIES = new RepositoryFactory() {
|
||||||
|
@Override
|
||||||
|
public <T, ID extends Serializable> Repository<T, ID> create(Tx tx, Class<T> type) {
|
||||||
|
return new HibernateRepository<T, ID>(tx, type) {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Data create(TxManager manager) {
|
||||||
|
Tx tx = new Tx(manager);
|
||||||
|
return new Data(tx, REPOSITORIES);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -114,18 +114,20 @@ public class HibernateTxManager implements TxManager {
|
|||||||
cleanupIfIdle();
|
cleanupIfIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
TxOutcome outcome;
|
||||||
try {
|
try {
|
||||||
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
|
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
|
||||||
s.session().getTransaction().rollback();
|
s.session().getTransaction().rollback();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
outcome = TxOutcome.ROLLED_BACK;
|
||||||
} else {
|
} else {
|
||||||
s.session().getTransaction().commit();
|
s.session().getTransaction().commit();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
|
outcome = TxOutcome.COMMITTED;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
cleanupIfIdle();
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
|
ResourceRegistry.fireSynchronizations(outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ public final class McpConfig {
|
|||||||
private final String authorizationServerIssuer;
|
private final String authorizationServerIssuer;
|
||||||
private final List<String> allowedOrigins;
|
private final List<String> allowedOrigins;
|
||||||
private final List<String> scopesSupported;
|
private final List<String> scopesSupported;
|
||||||
private final String rolesClaimPath;
|
|
||||||
|
|
||||||
private McpConfig(Builder b) {
|
private McpConfig(Builder b) {
|
||||||
this.name = b.name;
|
this.name = b.name;
|
||||||
@@ -41,7 +40,6 @@ public final class McpConfig {
|
|||||||
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
||||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||||
this.scopesSupported = List.copyOf(b.scopesSupported);
|
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||||
this.rolesClaimPath = b.rolesClaimPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String name() { return name; }
|
String name() { return name; }
|
||||||
@@ -54,7 +52,6 @@ public final class McpConfig {
|
|||||||
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
||||||
List<String> allowedOrigins() { return allowedOrigins; }
|
List<String> allowedOrigins() { return allowedOrigins; }
|
||||||
List<String> scopesSupported() { return scopesSupported; }
|
List<String> scopesSupported() { return scopesSupported; }
|
||||||
String rolesClaimPath() { return rolesClaimPath; }
|
|
||||||
|
|
||||||
public static Builder builder(String name) { return new Builder(name); }
|
public static Builder builder(String name) { return new Builder(name); }
|
||||||
|
|
||||||
@@ -69,7 +66,6 @@ public final class McpConfig {
|
|||||||
private String authorizationServerIssuer;
|
private String authorizationServerIssuer;
|
||||||
private final List<String> allowedOrigins = new ArrayList<>();
|
private final List<String> allowedOrigins = new ArrayList<>();
|
||||||
private final List<String> scopesSupported = new ArrayList<>();
|
private final List<String> scopesSupported = new ArrayList<>();
|
||||||
private String rolesClaimPath = "realm_access.roles";
|
|
||||||
|
|
||||||
private Builder(String name) {
|
private Builder(String name) {
|
||||||
if (name == null || name.isBlank())
|
if (name == null || name.isBlank())
|
||||||
@@ -132,15 +128,6 @@ public final class McpConfig {
|
|||||||
*/
|
*/
|
||||||
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim path used to resolve roles for {@code @RolesAllowed} on an {@link McpTool} —
|
|
||||||
* same dot-path syntax and default (Keycloak's {@code realm_access.roles}) as {@code
|
|
||||||
* OidcConfig#rolesClaimPath()}. Set this only if the two configs diverge; there is no
|
|
||||||
* way to auto-derive it from the installed {@code OidcExtension} (see {@code
|
|
||||||
* docs/security.md}'s {@code @RolesAllowed}/{@code @ScopesAllowed} section for why).
|
|
||||||
*/
|
|
||||||
public Builder rolesClaimPath(String rolesClaimPath) { this.rolesClaimPath = rolesClaimPath; return this; }
|
|
||||||
|
|
||||||
public McpConfig build() {
|
public McpConfig build() {
|
||||||
if (toolsPackage == null || toolsPackage.isBlank())
|
if (toolsPackage == null || toolsPackage.isBlank())
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
|
|||||||
+2
-1
@@ -61,7 +61,8 @@ public class McpExtension implements FlashExtension {
|
|||||||
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
|
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
|
||||||
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
|
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
|
||||||
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
|
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
|
||||||
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, config.rolesClaimPath());
|
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null,
|
||||||
|
secured == null ? null : secured.rolesClaimPath());
|
||||||
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
||||||
|
|
||||||
List<Middleware> chain = new ArrayList<>(3);
|
List<Middleware> chain = new ArrayList<>(3);
|
||||||
|
|||||||
+3
-2
@@ -46,7 +46,8 @@ final class McpOidcIntegration {
|
|||||||
private McpOidcIntegration() {}
|
private McpOidcIntegration() {}
|
||||||
|
|
||||||
/** Everything {@link McpExtension} needs once oidc security is resolved. */
|
/** Everything {@link McpExtension} needs once oidc security is resolved. */
|
||||||
record Resolved(Middleware security, String issuer, Function<Request, String> resourceIdentifier) {}
|
record Resolved(Middleware security, String issuer, String rolesClaimPath,
|
||||||
|
Function<Request, String> resourceIdentifier) {}
|
||||||
|
|
||||||
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
||||||
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
||||||
@@ -63,7 +64,7 @@ final class McpOidcIntegration {
|
|||||||
|
|
||||||
Middleware protect = oidcMw.protect(resourceMetadataPath);
|
Middleware protect = oidcMw.protect(resourceMetadataPath);
|
||||||
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
||||||
return new Resolved(secured, issuer, resourceId);
|
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+4
-85
@@ -2,14 +2,10 @@ package dev.relism.flash.ext.mcp;
|
|||||||
|
|
||||||
import dev.relism.flash.exceptions.InitializationException;
|
import dev.relism.flash.exceptions.InitializationException;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.lang.reflect.Modifier;
|
import java.lang.reflect.Modifier;
|
||||||
import java.net.URL;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Enumeration;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.jar.JarEntry;
|
import dev.relism.flash.extension.PackageScanner;
|
||||||
import java.util.jar.JarFile;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds
|
* Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds
|
||||||
@@ -37,40 +33,11 @@ final class McpPackageScanner {
|
|||||||
if (packageName == null || packageName.isBlank())
|
if (packageName == null || packageName.isBlank())
|
||||||
throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name");
|
throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name");
|
||||||
|
|
||||||
String resourcePath = packageName.replace('.', '/');
|
|
||||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
|
||||||
List<Class<? extends McpTool>> tools = new ArrayList<>();
|
List<Class<? extends McpTool>> tools = new ArrayList<>();
|
||||||
List<Class<? extends McpResource>> resources = new ArrayList<>();
|
List<Class<? extends McpResource>> resources = new ArrayList<>();
|
||||||
List<Class<? extends McpPrompt>> prompts = new ArrayList<>();
|
List<Class<? extends McpPrompt>> prompts = new ArrayList<>();
|
||||||
List<String> errors = new ArrayList<>();
|
List<String> errors = new ArrayList<>();
|
||||||
boolean packageFound = false;
|
for (Class<?> cls : PackageScanner.discover(packageName)) tryLoad(cls, tools, resources, prompts, errors);
|
||||||
|
|
||||||
try {
|
|
||||||
Enumeration<URL> urls = cl.getResources(resourcePath);
|
|
||||||
while (urls.hasMoreElements()) {
|
|
||||||
packageFound = true;
|
|
||||||
URL url = urls.nextElement();
|
|
||||||
String protocol = url.getProtocol();
|
|
||||||
if ("file".equals(protocol)) {
|
|
||||||
scanDirectory(new File(url.toURI()), packageName, cl, tools, resources, prompts, errors);
|
|
||||||
} else if ("jar".equals(protocol)) {
|
|
||||||
String jarPath = url.getPath();
|
|
||||||
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
|
|
||||||
try (JarFile jar = new JarFile(filePart)) {
|
|
||||||
scanJar(jar, resourcePath, cl, tools, resources, prompts, errors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (InitializationException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new InitializationException("Failed to scan MCP package: " + packageName, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!packageFound)
|
|
||||||
throw new InitializationException(
|
|
||||||
"McpConfig.toolsPackage(\"" + packageName + "\") — package not found on classpath. " +
|
|
||||||
"Verify the package name and ensure the module is on the classpath.");
|
|
||||||
|
|
||||||
if (!errors.isEmpty())
|
if (!errors.isEmpty())
|
||||||
throw new InitializationException(
|
throw new InitializationException(
|
||||||
@@ -86,55 +53,13 @@ final class McpPackageScanner {
|
|||||||
return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts));
|
return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void scanDirectory(File dir, String packageName, ClassLoader cl,
|
|
||||||
List<Class<? extends McpTool>> tools,
|
|
||||||
List<Class<? extends McpResource>> resources,
|
|
||||||
List<Class<? extends McpPrompt>> prompts,
|
|
||||||
List<String> errors) {
|
|
||||||
File[] files = dir.listFiles();
|
|
||||||
if (files == null) return;
|
|
||||||
for (File file : files) {
|
|
||||||
if (file.isDirectory()) {
|
|
||||||
scanDirectory(file, packageName + '.' + file.getName(), cl, tools, resources, prompts, errors);
|
|
||||||
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
|
|
||||||
String className = packageName + '.' + file.getName().replace(".class", "");
|
|
||||||
tryLoad(className, cl, tools, resources, prompts, errors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void scanJar(JarFile jar, String resourcePath, ClassLoader cl,
|
|
||||||
List<Class<? extends McpTool>> tools,
|
|
||||||
List<Class<? extends McpResource>> resources,
|
|
||||||
List<Class<? extends McpPrompt>> prompts,
|
|
||||||
List<String> errors) {
|
|
||||||
String prefix = resourcePath + "/";
|
|
||||||
Enumeration<JarEntry> entries = jar.entries();
|
|
||||||
while (entries.hasMoreElements()) {
|
|
||||||
String name = entries.nextElement().getName();
|
|
||||||
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) {
|
|
||||||
String className = name.replace('/', '.').replace(".class", "");
|
|
||||||
tryLoad(className, cl, tools, resources, prompts, errors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isAnonymous(String fileName) {
|
|
||||||
int dollar = fileName.lastIndexOf('$');
|
|
||||||
if (dollar < 0) return false;
|
|
||||||
int next = dollar + 1;
|
|
||||||
while (next < fileName.length() && fileName.charAt(next) == '$') next++;
|
|
||||||
return next < fileName.length() && Character.isDigit(fileName.charAt(next));
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static void tryLoad(String className, ClassLoader cl,
|
private static void tryLoad(Class<?> cls,
|
||||||
List<Class<? extends McpTool>> tools,
|
List<Class<? extends McpTool>> tools,
|
||||||
List<Class<? extends McpResource>> resources,
|
List<Class<? extends McpResource>> resources,
|
||||||
List<Class<? extends McpPrompt>> prompts,
|
List<Class<? extends McpPrompt>> prompts,
|
||||||
List<String> errors) {
|
List<String> errors) {
|
||||||
try {
|
try {
|
||||||
Class<?> cls = cl.loadClass(className);
|
|
||||||
if (Modifier.isAbstract(cls.getModifiers())) return;
|
if (Modifier.isAbstract(cls.getModifiers())) return;
|
||||||
|
|
||||||
if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) {
|
if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) {
|
||||||
@@ -151,13 +76,7 @@ final class McpPackageScanner {
|
|||||||
assertNoArgConstructor(cls, errors);
|
assertNoArgConstructor(cls, errors);
|
||||||
prompts.add((Class<? extends McpPrompt>) cls);
|
prompts.add((Class<? extends McpPrompt>) cls);
|
||||||
}
|
}
|
||||||
} catch (ClassNotFoundException e) {
|
} catch (LinkageError e) { errors.add(cls.getName() + " — linkage error: " + e.getMessage()); }
|
||||||
errors.add(className + " — class not found: " + e.getMessage());
|
|
||||||
} catch (NoClassDefFoundError e) {
|
|
||||||
errors.add(className + " — missing dependency: " + e.getMessage());
|
|
||||||
} catch (LinkageError e) {
|
|
||||||
errors.add(className + " — linkage error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
|
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
|
||||||
|
|||||||
+1
-2
@@ -45,8 +45,7 @@ final class McpRegistry {
|
|||||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
|
||||||
* rejected at boot as a misconfiguration; see
|
* rejected at boot as a misconfiguration; see
|
||||||
* {@link McpOidcIntegration#compileToolPolicy}.
|
* {@link McpOidcIntegration#compileToolPolicy}.
|
||||||
* @param rolesClaimPath claim path forwarded to {@code @RolesAllowed} checks; see
|
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
|
||||||
* {@link McpConfig#rolesClaimPath(String)}.
|
|
||||||
*/
|
*/
|
||||||
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
|
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
|
||||||
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
||||||
|
|||||||
+3
@@ -60,6 +60,9 @@ public class OidcMiddleware {
|
|||||||
|
|
||||||
// -- Public API -----------------------------------------------------------
|
// -- Public API -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** The single configured claim path used by every transport for role checks. */
|
||||||
|
public String rolesClaimPath() { return config.rolesClaimPath(); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the bearer token or session cookie. Browser clients are redirected
|
* Validates the bearer token or session cookie. Browser clients are redirected
|
||||||
* to the login page on failure; API clients receive 401.
|
* to the login page on failure; API clients receive 401.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import java.util.jar.JarFile;
|
|||||||
* class cannot be loaded, an {@link InitializationException} is thrown immediately.
|
* class cannot be loaded, an {@link InitializationException} is thrown immediately.
|
||||||
* A clear crash at boot is always preferable to a server that starts "empty".
|
* A clear crash at boot is always preferable to a server that starts "empty".
|
||||||
*/
|
*/
|
||||||
final class PackageScanner {
|
public final class PackageScanner {
|
||||||
|
|
||||||
private PackageScanner() {}
|
private PackageScanner() {}
|
||||||
|
|
||||||
@@ -41,6 +41,60 @@ final class PackageScanner {
|
|||||||
return scan(packageName).httpHandlers();
|
return scan(packageName).httpHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared deterministic classpath discovery primitive for Flash extensions. It owns the
|
||||||
|
* directory/JAR traversal, anonymous-class exclusion and load diagnostics; extensions only
|
||||||
|
* classify the returned classes according to their own component contract.
|
||||||
|
*/
|
||||||
|
public static List<Class<?>> discover(String packageName) {
|
||||||
|
if (packageName == null || packageName.isBlank())
|
||||||
|
throw new InitializationException("scan() called with null or blank package name");
|
||||||
|
String resourcePath = packageName.replace('.', '/');
|
||||||
|
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||||
|
List<Class<?>> result = new ArrayList<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
boolean found = false;
|
||||||
|
try {
|
||||||
|
Enumeration<URL> resources = cl.getResources(resourcePath);
|
||||||
|
while (resources.hasMoreElements()) {
|
||||||
|
found = true;
|
||||||
|
URL url = resources.nextElement();
|
||||||
|
if ("file".equals(url.getProtocol())) discoverDirectory(new File(url.toURI()), packageName, cl, result, errors);
|
||||||
|
else if ("jar".equals(url.getProtocol())) {
|
||||||
|
String path = url.getPath();
|
||||||
|
try (JarFile jar = new JarFile(path.substring(path.indexOf("file:") + 5, path.indexOf('!')))) {
|
||||||
|
discoverJar(jar, resourcePath, cl, result, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) { throw new InitializationException("Failed to scan package: " + packageName, e); }
|
||||||
|
if (!found) throw new InitializationException("scan(\"" + packageName + "\") — package not found on classpath");
|
||||||
|
if (!errors.isEmpty()) throw new InitializationException("scan(\"" + packageName + "\") — failed to load classes:\n • " + String.join("\n • ", errors));
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void discoverDirectory(File dir, String packageName, ClassLoader cl, List<Class<?>> result, List<String> errors) {
|
||||||
|
File[] files = dir.listFiles(); if (files == null) return;
|
||||||
|
for (File file : files) {
|
||||||
|
if (file.isDirectory()) discoverDirectory(file, packageName + '.' + file.getName(), cl, result, errors);
|
||||||
|
else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) discoverClass(packageName + '.' + file.getName().replace(".class", ""), cl, result, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void discoverJar(JarFile jar, String resourcePath, ClassLoader cl, List<Class<?>> result, List<String> errors) {
|
||||||
|
String prefix = resourcePath + "/";
|
||||||
|
Enumeration<JarEntry> entries = jar.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
String name = entries.nextElement().getName();
|
||||||
|
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) discoverClass(name.replace('/', '.').replace(".class", ""), cl, result, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void discoverClass(String name, ClassLoader cl, List<Class<?>> result, List<String> errors) {
|
||||||
|
try { result.add(cl.loadClass(name)); }
|
||||||
|
catch (ClassNotFoundException | LinkageError e) { errors.add(name + " — " + e.getMessage()); }
|
||||||
|
}
|
||||||
|
|
||||||
static ScanResult scan(String packageName) {
|
static ScanResult scan(String packageName) {
|
||||||
if (packageName == null || packageName.isBlank())
|
if (packageName == null || packageName.isBlank())
|
||||||
throw new InitializationException("scan() called with null or blank package name");
|
throw new InitializationException("scan() called with null or blank package name");
|
||||||
|
|||||||
Reference in New Issue
Block a user