Skip to content

Security & Audit

Role-based access control (RBAC)

Permissions are declared per entity per role in the manifest:

yaml
permissions:
  Admin:      [read, delete]
  Accountant: [create, read, update]
  Viewer:     [read]

The available operations are create, read, update, and delete. The semantics are OR: any role the current user holds that is listed for the requested operation grants access. A Viewer can read. An Admin can read or delete, but not create or update.

The Maven plugin translates these into Elide @ReadPermission, @CreatePermission, @UpdatePermission, and @DeletePermission annotations on the generated entity class.

Roles are named strings declared in a RoleDefinition manifest and assigned to users at account creation or update time. The billing demo uses two domain roles: Accountant and Viewer.

Note: SuperAdmin and TenantAdmin are platform authorities stored as boolean flags on AperturePrincipal, not domain roles. Do not declare them in RoleDefinition manifests or reference them in entity permissions; they are for platform-level management operations only.

Attribute-based access control (ABAC)

ABAC policies are SpEL expressions evaluated against the current principal's attributes at request time. They are declared as AbacPolicy manifests:

yaml
apiVersion: aperture.itsjool.com/v1
kind: AbacPolicy
metadata:
  name: FinanceTeamOnly
spec:
  expression: "#user.securityAttributes['department'] == 'finance'"
yaml
apiVersion: aperture.itsjool.com/v1
kind: AbacPolicy
metadata:
  name: EuRegionOnly
spec:
  expression: "#user.securityAttributes['region'] == 'eu'"

Policies are referenced by name in the entity manifest:

yaml
policies:
  FinanceTeamOnly: [read, update]
  EuRegionOnly:    [read, update]

The semantics are AND (per policy) combined with RBAC (OR):

  1. The user's role must grant the operation (RBAC, OR semantics)
  2. All listed policies for that operation must pass (ABAC, AND semantics)

A user with the Accountant role and department: finance and region: eu can read and update invoices. An Accountant without department: finance cannot, even though the role grants access. Both FinanceTeamOnly and EuRegionOnly must pass.

Security attributes are admin-assigned claims stored in the JSONB securityAttributes column on the user record. Administrators set them at account creation or when updating a user; ordinary users cannot modify their own security attributes. ABAC policies enforce attribute-based access by checking these admin-controlled values.

Field-level encryption

Individual fields can be encrypted at rest by adding encrypted: true in the manifest:

yaml
fields:
  email:
    type: string
    encrypted: true

The implementation uses AES-256-GCM with a random 12-byte IV generated per value. The encrypted value is stored in PostgreSQL as a Base64-encoded string: IV (12 bytes) || ciphertext || GCM auth tag.

The encryption key is a 32-byte Base64-encoded value provided through an environment variable, never in code:

yaml
aperture:
  encryption:
    local:
      key: ${APERTURE_ENCRYPTION_KEY}

What "encrypted at rest" means here: the column value in PostgreSQL is ciphertext. An attacker with direct database access sees Base64 strings, not plaintext. Decryption happens in the JVM at read time. The key must be present and correct or decryption fails.

Deterministic encryption (same IV derived from plaintext SHA-256) is also supported for fields that need unique: constraints, since random IVs produce different ciphertexts for the same value.

Optimistic locking

Add optimisticLocking: true to an entity to require concurrent-update protection:

yaml
spec:
  optimisticLocking: true

The changeset generator adds a version INTEGER NOT NULL DEFAULT 0 column. Every API response includes an ETag header containing the current version. PUT, PATCH, and DELETE requests must include the If-Match header:

bash
# Fetch resource and note ETag
curl -I http://localhost:8080/api/v1/customers/1 \
  -H "Authorization: Bearer $TOKEN"
# ETag: "0"

# Update with ETag
curl -X PATCH http://localhost:8080/api/v1/customers/1 \
  -H "Authorization: Bearer $TOKEN" \
  -H "If-Match: \"0\"" \
  -H "Content-Type: application/vnd.api+json" \
  -d '...'
ScenarioResponse
If-Match matches current version200 OK, version incremented
If-Match is stale (another write occurred)412 Precondition Failed
If-Match header missing428 Precondition Required

Use optimistic locking for high-contention entities (e.g., Customer) where silent overwrites of concurrent edits would cause data loss.

Rate limiting

The built-in rate limiter uses three independent token buckets keyed by IP, user, and tenant. The default limits match the runtime defaults and are configurable:

yaml
aperture:
  rate-limit:
    enabled: true
    backend: valkey
    ip:
      capacity: 100
      refillTokens: 100
      windowSeconds: 60
    user:
      capacity: 50
      refillTokens: 50
      windowSeconds: 60
    tenant:
      capacity: 500
      refillTokens: 500
      windowSeconds: 60
    valkey:
      host: valkey
      port: 6379

The ip bucket has no trusted-proxy support today. The ip key is taken directly from HttpServletRequest.getRemoteAddr(). There is no X-Forwarded-For / Forwarded header handling and no ForwardedHeaderFilter configured. Behind any reverse proxy or load balancer, getRemoteAddr() returns the proxy's address for every request, so all clients behind that proxy share one ip-keyed bucket. Because the ip bucket is checked before the user and tenant buckets, one noisy client can exhaust it and cause every other client sharing that proxy to receive 429s. If you deploy behind a reverse proxy or load balancer, be aware that ip-based rate limiting is effectively shared across all clients until this gets trusted-proxy support.

When a request is rate-limited, Aperture returns 429 Too Many Requests with headers:

X-RateLimit-Limit: 50
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1750000000

The RateLimitProvider SPI allows swapping the implementation. The reference implementation keeps state in memory and suits single-instance deployments. The demo uses a Valkey-backed provider that loads a small Lua function library once and then executes FCALL on each request. For distributed/multi-instance deployments, implement RateLimitProvider backed by a shared store. Valkey is a good Redis-compatible option; to keep request overhead low, prefer no persistence or RDB-only snapshots instead of AOF.

Fail-open on backend errors: RateLimitFilter runs ahead of every request in the app (HIGHEST_PRECEDENCE + 20). If the configured RateLimitProvider throws, for example because of a dropped Valkey connection, the filter does not propagate the exception. It logs a single WARN and allows the request through, as if the limit were not exceeded. This is deliberate: letting the exception escape would turn a transient backend outage into a 500 for the entire application. Real limit breaches (the provider returning normally with allowed=false) are unaffected and still produce a 429. Each fail-open increments the aperture.ratelimit.failopen counter (tagged type=ip|user|tenant), so a sustained backend outage that disables rate limiting is alertable. Rejections are likewise counted on aperture.ratelimit.rejections (same type tag) by both the in-memory and Valkey-backed providers.

The audit trail

Every CREATE, UPDATE, and DELETE operation is written to an audit log. The JdbcAuditWriter implementation uses a background queue and batch inserts for low write amplification:

sql
-- aperture_audit_log schema
id         UUID     PRIMARY KEY
user_id    TEXT     NOT NULL
tenant_id  TEXT
entity     TEXT     NOT NULL
entity_id  TEXT     NOT NULL
operation  TEXT     NOT NULL     -- CREATE, UPDATE, DELETE
timestamp  TIMESTAMPTZ NOT NULL
details    JSONB                 -- changed field path plus before/after values

Each AuditEvent carries: userId, tenantId, entity (entity type name), entityId, operation, detailsJson, and occurredAt. occurredAt is the moment the audited change actually happened. AuditBridge captures it when the Elide lifecycle hook fires (i.e. when the transaction committed), not when a downstream AuditWriter gets around to persisting or shipping it. The pipeline is asynchronous (AuditBridge dispatches on an executor; WebhookAuditWriter batches and flushes on an interval), so the two instants can legitimately differ; occurredAt is the one that matters for correlating events across systems. It is an Instant, serialized by Jackson as an ISO-8601 UTC string (the ...Z suffix). Essentially every mainstream language's standard library can parse it natively, which matters because the audit trail is typically consumed cross-language by the webhook/SIEM sink on the other end. For Elide change events, detailsJson is valid JSON with the changed field path and before/after values:

json
{
  "fieldPath": "status",
  "before": "draft",
  "after": "approved"
}

Update details are captured from field-level Elide change events. Create and delete events can have empty details or null before/after values depending on what Elide reports for the lifecycle event.

Write guarantee: JdbcAuditWriter uses an in-memory queue with a capacity of 10,000 events processed by a single background thread. Events are dispatched after the Elide transaction commits. They are not in the same transaction, so audit write failures do not roll back the operation. If you need transactional audit guarantees (fail the mutation if audit fails), implement AuditWriter with a transactional outbox pattern.

Querying audit logs: GET /manage/audit (requires SuperAdmin or same-tenant TenantAdmin). Supports filtering by tenantId, entity, entityId, userId, from, and to. from and to are ISO timestamp values compared against the audit event timestamp. Tenant admins are always scoped to their own tenant, even if they pass a different tenantId.

Encrypted fields are redacted in the audit trail by default. AuditBridge.buildDetailsJson builds details from Elide's ChangeSpec.getOriginal()/getModified() values. These are the in-memory Java entity-attribute values that Elide's change tracking captures before Hibernate's JPA AttributeConverter runs the field's EncryptionService conversion at the JDBC boundary. Left alone, that would mean a field marked encrypted: true in the manifest appears in plaintext in details, both in the JDBC audit log and in whatever any custom AuditWriter forwards downstream (e.g. WebhookAuditWriter posting to an external SIEM). To close that gap, CodeGenerator emits a runtime-queryable @Encrypted marker annotation on every generated field where the manifest declares encrypted: true. This marker is purely additive and has no effect on the encryption mechanism itself. AuditBridge looks it up per change via EntityDictionary and, when a changed field carries the marker, replaces both before and after with the sentinel "[REDACTED]" rather than the real value. The JSON keeps the same shape and keys, but carries a redacted value.

This is on by default; it is configured via aperture.audit.redaction.*:

yaml
aperture:
  audit:
    redaction:
      enabled: true          # default true; redact encrypted fields by default
      exemptions:
        - entity: Customer
          field: taxId

redaction.enabled: false is an explicit, global opt-out. Every encrypted: true field then appears in plaintext in the audit trail again, just as it did before this feature existed. exemptions is a precise, auditable allowlist of individual entity/field pairs that should not be redacted despite being encrypted, for cases where you specifically need the real before/after value in the audit trail for a given field. Prefer a narrow exemption over the global switch wherever possible. It documents exactly which fields were deliberately left in plaintext instead of turning redaction off everywhere.

Relationship-only updates produce no audit row: the generated UPDATE audit hook is bound per scalar field (CodeGenerator, gated on field.targetClass() == null) and deliberately excludes relationship fields. Their ChangeSpec before/after values are entity references or collections, and serializing those risks a LazyInitializationException, unbounded/circular serialization, or leaking a whole related entity into the audit trail. There is also no class-level UPDATE binding to fall back to (only CREATE and DELETE are bound at the class level). As a result, an UPDATE that changes only a relationship, such as reassigning Invoice.customer or Task.assignee, fires no audit hook at all. Zero audit rows are written for that change in JDBC or any downstream AuditWriter. This is a real gap in the "every CREATE, UPDATE, and DELETE operation is written" guarantee above: it holds for scalar-field changes, but not for relationship-only changes. If your compliance requirements need a row for every UPDATE regardless of which fields changed, you currently need a custom lifecycle hook bound at the class level for UPDATE/POSTCOMMIT.

Custom audit destinations: implement the AuditWriter SPI to route audit events to Kafka, S3, a SIEM, or any other destination. The demos/aperture-audit-demo project shows a composite writer that keeps JDBC audit queries available while also POSTing audit batches to a WireMock SIEM-style HTTP sink.

java
public interface AuditWriter {
    void write(AuditEvent event);
}

aperture-audit-webhook

aperture-audit-webhook is a reusable AuditWriter implementation that batches audit events and POSTs them to an HTTP endpoint. The demos/aperture-audit-demo module uses it to ship events to a SIEM-style sink.

Maven coordinates:

xml
<dependency>
  <groupId>com.itsjool.aperture</groupId>
  <artifactId>aperture-audit-webhook</artifactId>
  <version>1.0.0-SNAPSHOT</version>
</dependency>

WebhookAuditWriter takes a required URI endpoint and three optional tuning parameters, defaulting to:

ParameterDefaultDescription
batchSize100Events are flushed once a batch reaches this size
flushIntervalDuration.ofSeconds(1)Events are also flushed on this interval, whichever comes first
queueCapacity10_000In-memory queue bound; once full, write(event) drops the event and logs a WARN rather than blocking

Wire it as a Spring bean:

java
@Configuration
class AuditWebhookConfig {
    @Bean
    WebhookAuditWriter webhookAuditWriter(@Value("${aperture.audit.webhook.url}") URI url) {
        return new WebhookAuditWriter(url); // batchSize=100, flushInterval=1s, queueCapacity=10000
    }
}

To combine it with the JDBC writer instead of replacing it (so a webhook outage doesn't lose the queryable audit trail), fan out from a composite AuditWriter bean. See demos/aperture-audit-demo's AuditDemoAuditConfiguration for the full pattern, including per-sink failure isolation.