Skip to content

Getting Started

A worked example: a support desk that reads customer emails, where the email body must never reach a vendor model and cardholder data must never reach any model.

0. Add the dependencies

Import the BOM once, and every Occlude module agrees on a version:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.jwcarman.occlude</groupId>
      <artifactId>occlude-bom</artifactId>
      <version>0.2.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

The BOM also pins Jackson 3 to 3.1.7, which fixes three CVEs in jackson-databind below 3.1.6. Spring Boot 4.1.1 manages 3.1.5. Maven uses the first imported BOM that manages an artifact. Thus, put occlude-bom before spring-boot-dependencies. If you put it after, Boot's 3.1.5 wins.

Your build Jackson that you get
occlude-bom imported before spring-boot-dependencies 3.1.7
occlude-bom imported after spring-boot-dependencies 3.1.5
spring-boot-starter-parent as the parent, with occlude-bom imported 3.1.7

These results come from Maven 3.9.16. To choose another Jackson, import your own tools.jackson:jackson-bom before occlude-bom. With spring-boot-starter-parent, the jackson-bom.version property does not change it, because occlude-bom is imported first.

Then one of three, depending on where you are:

You have Add
Spring Boot and Postgres org.jwcarman.occlude:occlude-spring-boot-starter, and the org.postgresql:postgresql driver
Plain Java and Postgres org.jwcarman.occlude:occlude-jdbc, org.jwcarman.codec:codec-jackson, and the Postgres driver
Nothing yet, and want to try it org.jwcarman.occlude:occlude-core, which includes MemoryStorage

Occlude does no cryptography or serialisation of its own. It uses codec (org.jwcarman.codec), a small library that turns values into bytes and encrypts, signs and versions them. occlude-jdbc brings its encryption in transitively; codec-jackson is how values become bytes, and the Spring starter includes it. You will meet codec's DataKeyProvider when you supply keys — see Storage.

1. Say what you ask about values

public final class BillingAxes {
  public enum Integrity { ENDORSED, UNENDORSED }
  public enum Sensitivity { ORDINARY, PERSONAL, CARDHOLDER }

  public static final Axis<String>      TENANT      = Axis.matching("tenant").required();
  public static final Axis<Integrity>   INTEGRITY   = Axis.ladder("integrity", ENDORSED, UNENDORSED);
  public static final Axis<Sensitivity> SENSITIVITY =
      Axis.ladder("sensitivity", ORDINARY, PERSONAL, CARDHOLDER);
}

Note which end of INTEGRITY is which. Untrusted data is the more constrained end, because it is the dangerous thing to handle — so vouching for something moves it down, which is why the operation is called lowering.

2. Constitute a charter

DefaultCharter charter = new DefaultCharter(Axes.of(TENANT, INTEGRITY, SENSITIVITY));

A charter is where authority is declared. It knows nothing yet about where values live or who is asking; both arrive when it is brought into force.

3. Declare the portals

static final OccludedType<Mail> MAIL = OccludedType.of("mail", Mail.class);

Occlude<Mail> customerMail = charter.source("customer-mail", MAIL,
    ctx -> Label.of(TENANT, ctx.get("tenant").orElseThrow())
                .with(INTEGRITY, UNENDORSED)
                .with(SENSITIVITY, PERSONAL));

Reveal<Mail> quarantinedLlm = charter.reveal("quarantined-llm",
    ctx -> Ceiling.of(TENANT, Constraint.any())
                  .with(INTEGRITY, Constraint.any())
                  .with(SENSITIVITY, Constraint.atMost(PERSONAL)),
    MAIL);

Reveal<Mail> vendorLlm = charter.reveal("vendor-llm",
    ctx -> Ceiling.of(TENANT, Constraint.any())
                  .with(INTEGRITY, Constraint.atMost(ENDORSED))
                  .with(SENSITIVITY, Constraint.atMost(ORDINARY)),
    MAIL);

The type's name is permanent. "mail" is written beside every value and checked when one is read back, so it behaves like a schema version: renaming it orphans what is stored. Say it yourself, as above, for anything that should outlive a refactor. OccludedType.of(Mail.class) derives one from the class instead — @OccludedName if it has one, otherwise the kebab-cased simple name — which drops any enclosing type, so two nested Invoice records collide and are refused at startup. A container needs a name and a TypeRef: OccludedType.of("cards", TypeRef.listOf(TypeRef.of(Card.class))).

Erasing a value and reading its label are portals too, each declared with what decides it:

Erasure compliance = charter.erasure("compliance",
    (label, ctx) -> ctx.has("erases-for-compliance", "true"));
Inspection supportDesk = charter.inspection("support-desk",
    ctx -> Ceiling.of(TENANT, Constraint.atMost(ctx.get("tenant").orElseThrow()))
                  .with(INTEGRITY, Constraint.any())
                  .with(SENSITIVITY, Constraint.any()));

Hold onto these. They are the authority, and each carries everything it needs; there is no way to look them up later.

4. Bring it into force

charter.bind(Bindings.of(new MemoryStorage()).withIdentity(() -> {
  Map<String, String> who = new LinkedHashMap<>();
  who.put("tenant", CurrentTenant.get());
  if (currentRoles().contains("compliance-officer")) {
    who.put("erases-for-compliance", "true");
  }
  return AccessContext.of(who);
}));

The provider turns the identity system's roles into the capabilities that the charter asks about. The erasure above asks "may this person erase for compliance?", not "which role does this person have?". The Spring guide says why.

Once, irreversibly. Before this, no portal works; after it, no further authority can be constituted, and the charter itself is no longer needed.

withIdentity is how identity reaches the gate without being threaded through every call. A ThreadLocal, a ScopedValue, Spring's holders — occlude has no opinion. It is supplied here, beside the storage, because it is where identity lives in this environment rather than authority the application grants. An application with no notion of identity says so with withoutIdentity() — there is no way to bind without deciding.

To watch every operation as a timer and a span, hand binding a Micrometer ObservationRegistry — Bindings.of(storage).withIdentity(...).observedBy(registry). Each observation carries the operation, the portal, the outcome and a refusal's reason, and never a value, an identifier, a label or an identity; the Spring Boot guide lists them. The Spring starter does this for you.

5. Use it

Occluded<Mail> held = customerMail.occlude(incoming);

vendorLlm.reveal(held);        // Denied — PERSONAL is above ORDINARY, and it is UNENDORSED
quarantinedLlm.reveal(held);   // Allowed

6. Print what you built

System.out.println(charter.manifest(AccessContext.of(Map.of("tenant", "acme"))));

See Reviewing a Charter for what to look for — and note the argument. A ceiling that reads the tenant cannot be rendered without one.

Next