# navigation in security predicates (any depth, either side)

> A `where` row filter may follow relations as far as the model goes — `Folder.Workspace.Region.Head == user` is a four-hop rule, and the principal side navigates too (`user.HomeRegion.Company`). There is no depth limit and no shape a shallow rule is allowed that a deep one is not: criteria on intermediate rows, several chains in one predicate, a chain inside a collection hop, negation and `== null` mid-chain all behave the same at any length. Read and write always agree, so a rule that grants you the read grants you the write.

<!-- id: security-navigation-predicates · area: security · stability: stable · html: https://osysharp.com/reference/security/navigation-predicates/ -->

## Summary        {#summary}
A `where` row filter is an ordinary query predicate, so it may **follow relations** to reach the fact that decides
access. The chain can be as long as your model:

```osy syntax
entity Note {
  [Required] Folder Folder;
  security {
    // four hops: this note's folder → its workspace → that workspace's region → the region's head.
    allow read, update where Folder.Workspace.Region.Head == user;
  }
}
```

**Nothing about depth changes the rules.** A four-hop rule may carry criteria on the rows it passes through, combine
several chains, sit inside a collection hop, or be negated — the same as a one-hop rule.

## Signature      {#signature}

```osy syntax
allow <verbs> where <chain> <op> <value>;         // <chain> is Rel.Rel….Property, any length
allow <verbs> where user.<chain> == <value>;      // the principal navigates too
```

## Description    {#description}

### Both sides navigate     {#both-sides}
The row under evaluation is reached with a bare property name, and the caller with `user`. Either may navigate:

```osy syntax
// the row's chain compared to the principal's chain
allow read where Folder.Workspace.Region.Company == user.HomeRegion.Company;
```

### A broken chain denies, it does not hide the row   {#null-chains}
If any relation along the chain is null, the comparison is **not true** — so that row satisfies no `allow` built on
the chain. It is *not* removed from consideration, which matters the moment the chain is one arm of something:

```osy syntax
// a workspace with no region is admitted by the LEFT arm; one with a region is decided by the right.
allow read where Folder.Workspace.Region == null || Folder.Workspace.Region.Head == user;
```

Both arms get their say for every row. A rule cannot accidentally exclude rows by mentioning a relation they lack.

### Read and write give the same answer     {#backend-parity}
Reads are answered by the database and writes are checked in memory at commit, but they evaluate **the same
predicate** and must return **the same verdict** — at every depth, on both sides of the comparison. You never have to
know which engine is deciding, and you never have to shorten a rule to keep them in agreement.

> **Do not denormalize a relation just to keep a rule short.** Copying a parent's key onto a child so the rule can
> say `Organization` instead of `Application.Organization` buys nothing here, and costs a column that can drift out
> of step with the relation it mirrors.

### Does a hop resolve against rows the caller cannot read?      {#unsecured-hops}
Following a relation resolves against the data **as it is**, not against the rows the caller may read. Deciding
authorization from what the caller can already see would be circular — you cannot read the membership row that grants
you the read. Only the final verdict reaches the caller; no row visited along the way is disclosed.

## Examples       {#examples}

```osy title="a four-hop rule, with criteria on the rows it passes through" test app=security-navigation-predicates
[Principal] entity User {
  string Email;
  security { allow read when IsAuthenticated; }
}

entity Region {
  string Name;
  [Required] User Head;
  security { allow read when IsAuthenticated; }
}

entity Workspace {
  string Name;
  Region Region;                                   // nullable — a workspace need not sit in a region
  [Required] bool Active;
  security { allow read when IsAuthenticated; }
}

enum FolderKind { Normal, Archived }

entity Folder {
  string Name;
  [Required] Workspace Workspace;
  [Required] FolderKind Kind;
  security { allow read when IsAuthenticated; }
}

entity Note {
  string Title;
  [Required] Folder Folder;
  security {
    // four hops to the deciding fact, plus criteria on TWO of the rows along the way.
    allow read, update where Folder.Workspace.Region.Head == user
                          && Folder.Workspace.Active
                          && Folder.Kind != FolderKind.Archived;
  }
}
```

```osy title="a chain inside a collection hop, correlated to the row" syntax
// a chain inside a collection hop, correlated to a chain on the row
allow read where RegionMember.Any(m => m.Region == Folder.Workspace.Region && m.User == user);
```

```osy title="the shape a denormalized FK used to exist for" syntax
// the shape a denormalized FK used to exist for — now written as it reads
allow update, delete where OrganizationMember.Any(o => o.Organization == Application.Organization
                                                    && o.User == user
                                                    && o.Role != OrgRole.Member);
```

## See also       {#see-also}
- [security { }](https://osysharp.com/reference/security/entity-security/) — the `security { }` block and its verbs
- [principal predicates (IsAuthenticated / IsAnonymous) and open reads](https://osysharp.com/reference/security/principal-predicates/) — `IsAuthenticated` / `IsAnonymous`
- [secure by default (deny-all)](https://osysharp.com/reference/security/secure-by-default/) — why an unqualified `allow` is refused
