feat: add @EnvPath resolver and security matchers for env-scoped URLs
Groundwork for the REST API taxonomy migration. Introduces the
infrastructure that future waves use to move data/query endpoints under
/api/v1/environments/{envSlug}/... without per-handler boilerplate.
- Add @EnvPath annotation + EnvironmentPathResolver: injects the
Environment identified by the {envSlug} path variable, 404 on unknown
slug, registered via WebConfig.addArgumentResolvers.
- Add env-scoped URL matchers to SecurityConfig (config, settings,
executions/stats/logs/routes/agents/apps/deployments under
/environments/*/**). Legacy flat matchers kept in place and will be
removed per-wave as controllers migrate. New agent-authoritative
/api/v1/agents/config matcher prepared for the agent/user split.
- Document OpenAPI schema regen workflow in CLAUDE.md so future API
changes cover schema.d.ts regeneration as part of the change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
CLAUDE.md
15
CLAUDE.md
@@ -67,6 +67,19 @@ PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
|
||||
|
||||
ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup)
|
||||
|
||||
## Regenerating OpenAPI schema (SPA types)
|
||||
|
||||
After any change to REST controller paths, request/response DTOs, or `@PathVariable`/`@RequestParam`/`@RequestBody` signatures, regenerate the TypeScript types the SPA consumes. Required for every controller-level change.
|
||||
|
||||
```bash
|
||||
# Backend must be running on :8081
|
||||
cd ui && npm run generate-api:live # fetches fresh openapi.json AND regenerates schema.d.ts
|
||||
# OR, if openapi.json was updated by other means:
|
||||
cd ui && npm run generate-api # regenerates schema.d.ts from existing openapi.json
|
||||
```
|
||||
|
||||
After regeneration, `ui/src/api/schema.d.ts` and `ui/src/api/openapi.json` will update. The TypeScript compiler then surfaces every SPA call site that needs updating — fix all compile errors before testing in the browser. Commit the regenerated files with the controller change.
|
||||
|
||||
## Maintaining .claude/rules/
|
||||
|
||||
When adding, removing, or renaming classes, controllers, endpoints, UI components, or metrics, update the corresponding `.claude/rules/` file as part of the same change. The rule files are the class/API map that future sessions rely on — stale rules cause wrong assumptions. Treat rule file updates like updating an import: part of the change, not a separate task.
|
||||
@@ -78,7 +91,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (6364 symbols, 16045 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (6404 symbols, 16118 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ package com.cameleer.server.app.config;
|
||||
import com.cameleer.server.app.analytics.UsageTrackingInterceptor;
|
||||
import com.cameleer.server.app.interceptor.AuditInterceptor;
|
||||
import com.cameleer.server.app.interceptor.ProtocolVersionInterceptor;
|
||||
import com.cameleer.server.app.web.EnvironmentPathResolver;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Web MVC configuration.
|
||||
*/
|
||||
@@ -16,13 +20,21 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
||||
private final AuditInterceptor auditInterceptor;
|
||||
private final UsageTrackingInterceptor usageTrackingInterceptor;
|
||||
private final EnvironmentPathResolver environmentPathResolver;
|
||||
|
||||
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
|
||||
AuditInterceptor auditInterceptor,
|
||||
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor) {
|
||||
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor,
|
||||
EnvironmentPathResolver environmentPathResolver) {
|
||||
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
||||
this.auditInterceptor = auditInterceptor;
|
||||
this.usageTrackingInterceptor = usageTrackingInterceptor;
|
||||
this.environmentPathResolver = environmentPathResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
|
||||
resolvers.add(environmentPathResolver);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -119,11 +119,37 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
|
||||
// Application config endpoints
|
||||
// Application config endpoints (legacy flat shape — removed as controllers migrate to /environments/{env}/...)
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/config/*").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
|
||||
.requestMatchers(HttpMethod.PUT, "/api/v1/config/*").hasAnyRole("OPERATOR", "ADMIN")
|
||||
|
||||
// Read-only data endpoints — viewer+
|
||||
// Agent-authoritative config (post-migration split from /api/v1/config/{app})
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/agents/config").hasRole("AGENT")
|
||||
|
||||
// Env-scoped config & settings (specific rules BEFORE the generic /apps/** OPERATOR rule)
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/config").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/config").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/processor-routes").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.PUT, "/api/v1/environments/*/apps/*/config").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/apps/*/config/test-expression").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers("/api/v1/environments/*/app-settings").hasRole("ADMIN")
|
||||
.requestMatchers("/api/v1/environments/*/apps/*/settings").hasRole("ADMIN")
|
||||
|
||||
// Env-scoped data reads (executions/stats/logs/routes/agents/diagrams)
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/errors/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/attributes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/logs").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/agents/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
|
||||
// Env-scoped app & deployment management (OPERATOR+) — catch-all for /environments/*/apps/**
|
||||
.requestMatchers("/api/v1/environments/*/apps/**").hasAnyRole("OPERATOR", "ADMIN")
|
||||
|
||||
// Read-only data endpoints — viewer+ (legacy flat shape — removed per-wave)
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
@@ -132,7 +158,7 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||
|
||||
// Runtime management (OPERATOR+)
|
||||
// Runtime management (OPERATOR+) — legacy flat shape
|
||||
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
|
||||
|
||||
// Admin endpoints
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.cameleer.server.app.web;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Injects the {@link com.cameleer.server.core.runtime.Environment} identified by the
|
||||
* {@code {envSlug}} path variable. Returns 404 if the slug does not exist.
|
||||
* <p>
|
||||
* Use on handlers under {@code /api/v1/environments/{envSlug}/...}:
|
||||
* <pre>{@code
|
||||
* @GetMapping("/api/v1/environments/{envSlug}/apps")
|
||||
* public List<App> list(@EnvPath Environment env) { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface EnvPath {
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.cameleer.server.app.web;
|
||||
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Resolves the {@code {envSlug}} path variable into an {@link Environment} for handlers
|
||||
* annotated with {@link EnvPath}. Validates the slug exists in the repository and rejects
|
||||
* unknown slugs with 404.
|
||||
* <p>
|
||||
* Paired with {@code SecurityConfig} role rules, this guarantees every env-scoped data
|
||||
* endpoint receives a real, authorized env without per-handler boilerplate.
|
||||
*/
|
||||
@Component
|
||||
public class EnvironmentPathResolver implements HandlerMethodArgumentResolver {
|
||||
|
||||
static final String ENV_SLUG_VARIABLE = "envSlug";
|
||||
|
||||
private final EnvironmentRepository environments;
|
||||
|
||||
public EnvironmentPathResolver(EnvironmentRepository environments) {
|
||||
this.environments = environments;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
return parameter.hasParameterAnnotation(EnvPath.class)
|
||||
&& Environment.class.equals(parameter.getParameterType());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object resolveArgument(MethodParameter parameter,
|
||||
ModelAndViewContainer mavContainer,
|
||||
NativeWebRequest request,
|
||||
WebDataBinderFactory binderFactory) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> vars = (Map<String, String>) request.getAttribute(
|
||||
HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE,
|
||||
RequestAttributes.SCOPE_REQUEST);
|
||||
String slug = vars != null ? vars.get(ENV_SLUG_VARIABLE) : null;
|
||||
if (slug == null || slug.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"Handler uses @EnvPath but path has no {envSlug} variable");
|
||||
}
|
||||
return environments.findBySlug(slug)
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "Unknown environment: " + slug));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user