Course Content
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
| Level | Question it answers | Example |
|---|---|---|
| Object-level | Can this user see this object type at all? | ”Couriers cannot see Customer” |
| Property-level | Within an object, are some properties restricted? | ”Only finance can read Customer.creditLimit” |
| Row-level | Within 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 markedRoles 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 HQWhen 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 restrictionA 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 restrictedAnyone 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 ruleTwo 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 metricAudit
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 reasonAudit 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 driftThese 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 reassignTest:
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. 🔐