Configuration
Aperture is configured via Spring Boot's application.yml. All properties under aperture.* are Aperture-specific. Standard Spring Boot properties (spring.datasource.*, spring.jpa.*, spring.liquibase.*) work as normal.
Database
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/aperture}
username: ${DB_USER:aperture}
password: ${DB_PASS:password}
driver-class-name: org.postgresql.Driver
jpa:
open-in-view: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
liquibase:
change-log: classpath:db/changelog/db.changelog-master.xmlAperture requires PostgreSQL. spring.liquibase.change-log must point to the root changelog generated by the build.
OneOf request validation (aperture.oneof-validation)
OneOf relationship writes are validated before JSON:API processing. The validator buffers matching JSON:API and Atomic Operations request bodies so it can reject undeclared member types and missing required relationships consistently.
| Property | Type | Default | Description |
|---|---|---|---|
aperture.oneof-validation.max-request-body-bytes | integer | 1048576 | Maximum buffered request body size in bytes. Larger validated requests receive HTTP 413. Increase this for intentionally large Atomic Operations batches. |
JWT authentication (aperture.auth.jwt)
| Property | Type | Default | Description |
|---|---|---|---|
aperture.auth.jwt.secret | string | None | Required. HMAC signing key. Use ${APERTURE_JWT_SECRET}. Minimum 32 bytes. |
aperture.auth.jwt.issuer | string | None | JWT iss claim value |
aperture.auth.jwt.audience | string | None | JWT aud claim value |
aperture.auth.jwt.access-duration | ISO 8601 duration | PT15M | Access token lifetime |
aperture.auth.refresh-duration | ISO 8601 duration | PT720H | Refresh token lifetime (30 days) |
aperture.auth.simple.enabled | boolean | true | Set false to disable all simple-auth endpoints (when using a custom CredentialValidator) |
Production warning: never use a hardcoded jwt.secret. Always read from an environment variable or secrets manager.
aperture:
auth:
jwt:
secret: ${APERTURE_JWT_SECRET}
issuer: my-app
audience: my-api
access-duration: PT15M
refresh-duration: PT720HCORS (aperture.cors)
| Property | Type | Default | Description |
|---|---|---|---|
aperture.cors.enabled | boolean | false | Enable CORS support |
aperture.cors.allowed-origins | list of strings | [] | Exact origins (e.g. https://app.example.com) |
aperture.cors.allowed-origin-patterns | list of strings | [] | Wildcard patterns (e.g. https://*.example.com) |
aperture.cors.max-age | long (seconds) | 3600 | Preflight cache duration |
Note: aperture.cors.enabled=true requires at least one origin or pattern. Aperture will refuse to start if CORS is enabled with no origins configured.
aperture:
cors:
enabled: true
allowed-origins:
- https://app.example.comRate limiting (aperture.rate-limit)
| Property | Type | Default | Description |
|---|---|---|---|
aperture.rate-limit.enabled | boolean | true | Set false to skip rate limiting entirely |
aperture.rate-limit.backend | string | memory | Rate-limit provider backend (memory or valkey) |
aperture.rate-limit.ip.capacity | integer | 100 | IP bucket maximum burst size |
aperture.rate-limit.ip.refillTokens | integer | 100 | IP bucket refill amount per window |
aperture.rate-limit.ip.windowSeconds | integer | 60 | IP bucket refill window |
aperture.rate-limit.user.capacity | integer | 50 | User bucket maximum burst size |
aperture.rate-limit.user.refillTokens | integer | 50 | User bucket refill amount per window |
aperture.rate-limit.user.windowSeconds | integer | 60 | User bucket refill window |
aperture.rate-limit.tenant.capacity | integer | 500 | Tenant bucket maximum burst size |
aperture.rate-limit.tenant.refillTokens | integer | 500 | Tenant bucket refill amount per window |
aperture.rate-limit.tenant.windowSeconds | integer | 60 | Tenant bucket refill window |
aperture.rate-limit.valkey.host | string | 127.0.0.1 | Valkey host used by the demo/provider |
aperture.rate-limit.valkey.port | integer | 6379 | Valkey port |
aperture.rate-limit.valkey.libraryName | string | aperture_rate_limit | Loaded Lua function library name |
aperture.rate-limit.valkey.functionName | string | consume | Function name invoked for each rate-limit check |
aperture.rate-limit.valkey.keyPrefix | string | aperture:rate-limit: | Prefix used for bucket keys in Valkey |
aperture:
rate-limit:
enabled: true
backend: memory
ip:
capacity: 100
refillTokens: 100
windowSeconds: 60
user:
capacity: 50
refillTokens: 50
windowSeconds: 60
tenant:
capacity: 500
refillTokens: 500
windowSeconds: 60
valkey:
host: localhost
port: 6379Field encryption (aperture.encryption.local)
| Property | Type | Default | Description |
|---|---|---|---|
aperture.encryption.local.key | string (Base64) | demo key | Must be overridden in production. 32-byte AES-256 key, Base64-encoded. |
Generate a key: openssl rand -base64 32
aperture:
encryption:
local:
key: ${APERTURE_ENCRYPTION_KEY}Hooks (aperture.hooks)
| Property | Type | Default | Description |
|---|---|---|---|
aperture.hooks.secret | string | default-hook-secret | Shared secret sent in X-Hook-Secret header |
aperture.hooks.base-url | string | (empty) | Override the host portion of all hook URLs |
aperture.hooks.timeout.commit | duration string | 5s | Timeout for the phase-gated validate (PRECOMMIT) and trigger (POSTCOMMIT) hook calls |
aperture.hooks.timeout.async | duration string | 30s | Timeout for guard (PRESECURITY) hook calls and, hardcoded regardless of phase, every mutate call |
aperture.hooks.timeout.connect | duration string | 2s | TCP connect timeout |
aperture:
hooks:
secret: ${APERTURE_HOOKS_SECRET}
timeout:
commit: 10s
async: 10s
connect: 3sMCP (aperture.mcp / spring.ai.mcp)
| Property | Type | Default | Description |
|---|---|---|---|
aperture.mcp.enabled | boolean | false | Activates ApertureMcpAutoConfiguration. With this unset or false, no MCP beans (adapter, tool callback provider, sanitization filter, tools/list filter) are registered at all, regardless of what else is on the classpath |
aperture.mcp.tool-list-scope | PRINCIPAL | STATIC | PRINCIPAL | PRINCIPAL scopes tools/list to the calling principal's roles and principal-only ABAC policies. STATIC restores the pre-phase-2 behavior: every generated tool is listed to every caller regardless of role |
spring.ai.mcp.server.protocol | string | STATELESS | MCP server mode. Aperture currently supports stateless HTTP |
spring.ai.mcp.server.streamable-http.mcp-endpoint | string | /mcp | HTTP endpoint for MCP JSON-RPC requests |
spring.ai.mcp.server.name | string | None | MCP server name shown to clients |
spring.ai.mcp.server.version | string | None | MCP server version shown to clients |
aperture.mcp.enabled controls Aperture's generated tool registration. Spring AI's spring.ai.mcp.server.* properties control the transport. For the current stateless HTTP transport, direct HTTP clients should send both Content-Type: application/json and Accept: application/json, text/event-stream.
There is no aperture.mcp.transport property. The manifest's spec.mcp.transport records intent and is validated during generation, but the transport the server actually speaks comes from spring.ai.mcp.server.protocol.
aperture:
mcp:
enabled: true
tool-list-scope: PRINCIPAL # or STATIC
spring:
ai:
mcp:
server:
protocol: STATELESS
name: aperture-api
version: 1.0.0
streamable-http:
mcp-endpoint: /mcpPrincipal-scoped tools/list
With tool-list-scope: PRINCIPAL (the default), a caller only sees the generated tools their domainRoles and principal-only ABAC policies (expressions referencing only #user, decidable with no row in hand) could plausibly succeed at. For example, a ReadOnly-role API key sees list_*/get_* tools but not create_*/update_*/delete_*. Superadmin principals always see every tool, and a TenantAdmin principal sees every tool of a tenant-scoped (or scopedBy) entity regardless of their own domainRoles, mirroring the TenantAdminCheck bypass Elide already grants those entities.
This is not an authorization boundary. It exists purely so an agent-facing tool list, which functions like a prompt on every conversation, does not tempt a model with tools its principal would never actually succeed at calling. tools/call is still authorized for real by Elide on every invocation, completely independent of what tools/list returned; a bug in this filtering can leak a tool name to a caller who shouldn't see it, but it can never leak data and can never let a call through that Elide would otherwise reject. See McpToolListFilter's javadoc in aperture-simple-mcp.
An ABAC policy expression referencing #record or #input is row-scoped and cannot be decided without a row in hand; such policies never participate in tools/list filtering. If a role also grants the same operation, the tool they apply to stays listed for that role, unfiltered by the row-scoped policy; Elide alone enforces it on the actual call. But if the operation is granted only by a row-scoped policy (no role grants it), the tool has no role to be listed under, so it is hidden from every caller except SuperAdmin and (for tenant-scoped/scopedBy entities) TenantAdmin. McpToolContribution tools have no entity or operation and are never registry-governed. They are always listed, regardless of tool-list-scope.
See Extending MCP for the two ways to add behavior beyond the generated per-entity tools: build-time tool contributions (McpToolContribution) and the runtime McpRequestAdapter bean seam.
GraphQL (elide.graphql)
Aperture's entity dictionary is also queryable over GraphQL, with the same entities, permissions, and manifests over a different transport. This is a native Elide/Spring property, not an aperture.* one, and it isn't manifest-driven: there's no per-entity or per-field GraphQL config, just a single on/off switch for the whole app.
| Property | Type | Default | Description |
|---|---|---|---|
elide.graphql.enabled | boolean | false | Enable the GraphQL endpoint |
elide.graphql.path | string | /graphql | Base path; the API version is appended, e.g. /graphql/v1 |
elide:
graphql:
enabled: true
path: /graphqlGraphQL requires the path-based API versioning strategy (elide.api-versioning-strategy.path.enabled: true; see API versioning); there is no unversioned /graphql route. Query and mutation field names are the plural JSON:API resource path (customers, invoices, lineitems), not the singular entity name, and results come back as Relay-style connections ({ edges { node { ... } } }).
oneof relationship fields are omitted from the generated GraphQL schema in this release. Their owning entities and ordinary relationships remain available. Use JSON:API, the generated CLI, or generated MCP tools to read and write the selected one-of member.
To disable GraphQL entirely, omit the elide.graphql block or set elide.graphql.enabled: false. This is also the default if the block is absent.
Tracing (management.tracing)
These are standard Spring Boot / Micrometer Tracing properties, not aperture.* ones. Aperture only supplies a default for the sampling probability.
| Property | Type | Default | Description |
|---|---|---|---|
management.tracing.sampling.probability | double | 0.1 | Fraction of requests traced. The 0.1 default is set by ApertureDefaultsEnvironmentPostProcessor; override to sample more (e.g. 1.0, as demos/aperture-demo does, for full tracing in a demo/test environment) |
management.otlp.tracing.endpoint | string | None | OTLP/HTTP trace exporter endpoint, e.g. http://jaeger:4318/v1/traces. Requires the OTel exporter dependency on the classpath |
management:
tracing:
sampling:
probability: 1.0
otlp:
tracing:
endpoint: "http://jaeger:4318/v1/traces"See Observability for the full tracing/metrics picture, including the spans and metrics Aperture emits once tracing is configured.
Security (aperture.server)
| Property | Type | Default | Description |
|---|---|---|---|
aperture.server.https-only | boolean | false | Reject HTTP requests and redirect to HTTPS |
OpenAPI / Swagger
Aperture includes Springdoc integration. Enable the Swagger UI:
springdoc:
swagger-ui:
enabled: true
path: /swagger-ui.html
api-docs:
enabled: true
path: /v3/api-docsWhen enabled, the generated OpenAPI spec includes all entity endpoints, auth and management endpoints, and request/response schemas derived from the manifests.
Bootstrap admin
There is no aperture.bootstrap.* property in Aperture, and the runtime/starter do not read APERTURE_BOOTSTRAP_ADMIN_PASSWORD. Automatic superadmin provisioning is not currently a general Aperture feature, despite what earlier revisions of this page implied. Setting aperture.bootstrap.admin-password in your own application.yml has no effect; nothing binds it.
What exists today is demo-specific: demos/aperture-demo ships its own DemoBootstrap component (active only when aperture.profile=demo) that creates a fixed superadmin@aperture.local user on ApplicationReadyEvent, reading the raw APERTURE_BOOTSTRAP_ADMIN_PASSWORD environment variable directly, not through any aperture.* configuration property. If your own application needs a bootstrap superadmin, write an equivalent ApplicationReadyEvent listener; see demos/aperture-demo/src/main/java/com/itsjool/aperture/demo/DemoBootstrap.java for the pattern.
Complete example (application.yml)
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASS}
driver-class-name: org.postgresql.Driver
jpa:
open-in-view: false
liquibase:
change-log: classpath:db/changelog/db.changelog-master.xml
aperture:
cors:
enabled: true
allowed-origins:
- ${FRONTEND_URL}
auth:
jwt:
secret: ${APERTURE_JWT_SECRET}
issuer: my-app
audience: my-api
access-duration: PT15M
refresh-duration: PT24H
encryption:
local:
key: ${APERTURE_ENCRYPTION_KEY}
hooks:
secret: ${APERTURE_HOOKS_SECRET}APERTURE_BOOTSTRAP_ADMIN_PASSWORD is not read by this configuration. See Bootstrap admin above.