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:
hsiegeln
2026-04-16 23:13:17 +02:00
parent 9b1ef51d77
commit c97d0ea061
5 changed files with 138 additions and 5 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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 {
}

View File

@@ -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));
}
}