Press ESC to exit fullscreen
📖 Lesson ⏱️ 75 minutes

Security, Permissions, and Markings

Object-level, property-level, and row-level access control with markings and policies

Why ontology security is special

In a normal application, security is enforced at the API boundary — every endpoint checks who’s calling and what they can do. In an ontology, the same data is consumed by dozens of apps, dashboards, agents, and services. Enforcing security in each consumer is impossible — drift is guaranteed.

The ontology solves this by pushing security one level down. Every read, every write, every function call passes through a single policy layer. Consumers do not — and cannot — bypass it.

This lesson sets up three levels of access control plus a system for classifying data and a permission model for actions.

Three levels of access

LevelQuestion it answersExample
Object-levelCan this user see this object type at all?”Couriers cannot see Customer
Property-levelWithin an object, are some properties restricted?”Only finance can read Customer.creditLimit
Row-levelWithin a property, do only some rows apply?”Drivers can only see shipments assigned to them”

Most ontologies need all three. They compose: a request first passes object-level, then property-level, then row-level — and the final result is whatever survives.

Identity and roles

Every request to the ontology carries an authenticated identity — a user, a service account, or an agent. The identity has zero or more roles:

# policies/roles.yaml
roles:
  ops_dispatcher:
    description: Operations team — dispatch and routing
    members: ["alice@northwind", "bob@northwind"]
  ops_supervisor:
    description: Operations leadership — can override exceptions
    inherits: [ops_dispatcher]
  finance:
    description: Finance — billing and reconciliation
  driver:
    description: Driver identities — strictly self-service
  agent_router:
    description: AI agent — route planning assistant
  admin:
    description: Platform admin — full access except as marked

Roles are flat enough to reason about; inheritance is allowed but should be shallow. Two or three levels deep is the limit before you cannot trace effective permissions.

Object-level policies

The first cut: who can see which object types at all.

# policies/object-policies.yaml
objectPolicies:
  Customer:
    read: [ops_dispatcher, ops_supervisor, finance, admin]
    # not in the list = no read access
  Shipment:
    read: [ops_dispatcher, ops_supervisor, finance, driver, agent_router, admin]
  Driver:
    read: [ops_dispatcher, ops_supervisor, admin]    # not exposed to driver role
  BillingAccount:
    read: [finance, admin]

Notice: driver does not appear in Driver.read. A driver cannot read other drivers’ records — only their own, which we handle with row-level rules below.

Property-level policies

Object-level is coarse. Property-level lets you hide sensitive fields:

objectPolicies:
  Customer:
    read: [ops_dispatcher, ops_supervisor, finance, admin]
    propertyOverrides:
      creditLimit:
        read: [finance, admin]
      taxId:
        read: [finance, admin]
      headquartersLocation:
        read: [ops_supervisor, finance, admin]   # ops_dispatcher does not see HQ

When a user without finance or admin reads a Customer, creditLimit and taxId come back redacted (or omitted, depending on the platform). The consumer code does not have to handle anything — the absence is reflected in the type.

Row-level policies

The most expressive — and the most error-prone. A row-level rule filters which instances a user can see, based on the instance’s properties and the user’s identity.

objectPolicies:
  Shipment:
    read: [ops_dispatcher, ops_supervisor, finance, driver, agent_router, admin]
    rowRules:
      - name: drivers-see-only-their-own
        applyToRoles: [driver]
        filter: shipment.assignedDriverId == identity.driverId
      - name: dispatchers-see-their-region
        applyToRoles: [ops_dispatcher]
        filter: |
          identity.regions.includes(shipment.originatingHub.region) ||
          identity.regions.includes(shipment.destinationHub.region)
      # ops_supervisor, finance, agent_router, admin: no row restriction

A few patterns worth highlighting:

1. Identity-property matching. A driver’s identity.driverId matches Shipment.assignedDriverId. You bake the identity claim mapping into the platform.

2. Region scoping. Dispatchers can see shipments touching their region. The traversal shipment.originatingHub.region is evaluated against the user’s regions claim.

3. Default deny. A role not addressed by a rowRule falls back to the object-level decision; a role not in read sees nothing regardless.

Row rules must be testable. Write tests:

it("a driver sees only their own shipments", async () => {
  const h = await loadFixtures("./tests/fixtures");
  const result = await h.as({ role: "driver", driverId: "drv_abc123" })
                        .query("Shipment.all()");
  expect(result.every(s => s.assignedDriverId === "drv_abc123")).toBe(true);
});

Untested row rules are guaranteed to leak data on the day you change them.

Markings

Markings are classifications of data — PII, Confidential, RegulatedHealthData, EU_Personal_Data. They propagate through queries: if a result includes a PII-marked column, the whole result carries the marking.

# policies/markings.yaml
markings:
  PII:
    description: Personally identifiable information
    requires: [pii_cleared]
  EU_Personal_Data:
    description: Subject to GDPR
    requires: [pii_cleared, gdpr_trained]
  Confidential:
    description: Internal-only commercial data
    requires: [confidential_cleared]

Then attach to properties:

# in objectPolicies.Customer
propertyOverrides:
  primaryEmail:
    markings: [PII, EU_Personal_Data]
  companyName:
    markings: [Confidential]
  taxId:
    markings: [PII, Confidential]
    read: [finance, admin]            # also property-level restricted

Anyone reading a primaryEmail must have the pii_cleared and gdpr_trained clearances. Anyone reading taxId needs both clearances and the finance/admin role.

Markings shine when results compose: an aggregation that touched EU_Personal_Data carries the marking even if the user only sees the count. The marking is the data’s, not the column’s.

Action permissions

Reads are not enough; writes need their own model.

# policies/action-policies.yaml
actionPolicies:
  placeOrder:
    invoke: [ops_dispatcher, ops_supervisor, admin]
  markShipmentDelivered:
    invoke: [ops_dispatcher, ops_supervisor, driver, admin]
    rowRules:
      - name: drivers-deliver-only-their-own
        applyToRoles: [driver]
        check: |
          let s = Shipment.byId(params.shipmentId)
          return s.assignedDriverId == identity.driverId
  assignDriverToShipment:
    invoke: [ops_dispatcher, ops_supervisor, admin]
    # driver role cannot reassign themselves to a different shipment
  changeCustomerTier:
    invoke: [ops_supervisor, finance, admin]
    requireApprovalBy: [admin]      # two-person rule

Two patterns to notice:

1. Per-action invocation rules. Not every role that can read can write. agent_router reads everything but cannot invoke assignDriverToShipment — assignment is a human decision.

2. Approval workflows. requireApprovalBy: [admin] introduces a two-person rule — the action is queued and only runs after admin approval. Useful for high-impact mutations.

Function-level controls

Functions are reads, so they follow the same object-/property-/row-level rules of the data they touch. But some functions deserve their own gate — especially those that invoke external models or expose aggregate insights:

functionPolicies:
  shipmentDelayRisk:
    invoke: [ops_dispatcher, ops_supervisor, agent_router, admin]
  customerLifetimeValue:
    invoke: [finance, ops_supervisor, admin]
    # ops_dispatcher cannot pull LTV — that's a finance/management metric

Audit

Every read, every write, every function call should be auditable:

  • Who (identity, roles claimed).
  • What (object IDs touched, properties read, action invoked).
  • When (server timestamp).
  • Why (request context, parent action ID, user-supplied reason).

For high-sensitivity actions, require a reason:

actionPolicies:
  rerouteShipment:
    invoke: [ops_dispatcher, ops_supervisor, admin]
    requireReason: true        # caller must supply a free-text reason

Audit logs should be queryable for compliance reviews — and immutable. They are not application logs; treat them as a separate, append-only store with retention matching your regulatory needs.

Principle of least privilege

The default for any new role should be “can read what they obviously need, can write nothing.” Expand from there, with a written justification per addition.

A useful exercise on day one: write each role’s responsibilities in plain English, then list the minimum object types and actions required. If a role’s description does not mention reading customer data, they should not get Customer.read.

A common pattern — the “system” identity

Background processes (ingestion, reconciliation, batch jobs) act on behalf of nobody. Give them a service identity with explicit roles:

serviceIdentities:
  ingestion-service:
    roles: [system_ingestion]
    description: Materializes datasources into the ontology
  reconciliation-job:
    roles: [system_reconciliation]
    description: Compares ontology to sources, alerts on drift

These identities should have narrowly tailored permissions — system_ingestion can only run ingestion actions, not user-facing ones. A bug in the ingestion job should not be able to mutate customer billing.

A worked example — driver self-service

We want drivers to:

  • See their own assigned shipments.
  • Mark them delivered.
  • View their home hub.

We do not want them to:

  • See other drivers’ shipments.
  • See customer data (only shipment fields).
  • Reassign themselves.

Full policy:

objectPolicies:
  Shipment:
    read: [..., driver, ...]
    rowRules:
      - name: drivers-own-shipments
        applyToRoles: [driver]
        filter: shipment.assignedDriverId == identity.driverId
  Hub:
    read: [..., driver, ...]
    rowRules:
      - name: drivers-home-hub
        applyToRoles: [driver]
        filter: hub.hubId == identity.homeHubId
  # Customer: driver not in read → fully invisible

actionPolicies:
  markShipmentDelivered:
    invoke: [..., driver, ...]
    rowRules:
      - name: drivers-own-shipments
        applyToRoles: [driver]
        check: |
          Shipment.byId(params.shipmentId).assignedDriverId == identity.driverId
  # assignDriverToShipment: driver not in invoke → cannot reassign

Test:

describe("driver self-service", () => {
  it("driver can read their own shipments and not others", async () => { ... });
  it("driver cannot see customer data", async () => { ... });
  it("driver cannot mark someone else's shipment delivered", async () => { ... });
  it("driver cannot invoke assignDriverToShipment", async () => { ... });
});

Four tests pin down four critical guarantees.

Anti-patterns

Anti-pattern 1 — Security as an afterthought. Adding policies after the ontology is in production means broken consumers and emergency rollbacks. Design policies alongside the model.

Anti-pattern 2 — Trusting client claims. A request that says “I am role admin” is not authoritative. Identities and roles come from an authenticated identity provider, not from the request body.

Anti-pattern 3 — Wildcard “admin everywhere” roles. A single role that bypasses everything is convenient and dangerous. If admin is needed, it should still pass through audit, and ideally require fresh re-auth for high-stakes actions.

Anti-pattern 4 — Untested row rules. Row rules are the most subtle and the most likely to leak data on edits. Test every one.

Anti-pattern 5 — Bypassing the ontology in “emergency.” “We just need to update the row directly to fix this one issue.” That is exactly when the audit trail matters most. Add an admin action, not a bypass.

Key takeaways

  • Push security into the ontology layer; consumers cannot enforce it consistently.
  • Three levels: object-, property-, row-. Use all three; default to least privilege.
  • Markings classify data and propagate with results.
  • Action permissions and approval workflows govern writes.
  • Test every row rule — they leak silently.

What’s next

Models age. Schemas evolve. Next: versioning, branching, and migrations — how to change the ontology safely in production.


Least privilege, by default. 🔐