Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

when is who, where is which rows

allow <verbs> when <who> where <which rows>;

A rule's `when` says WHO it is for and its `where` says WHICH ROWS. A rule whose `when` does not hold is never asked, so each kind of caller pays only for its own rules. A caller test inside a `where` is a compile error.

stable1 example compiled by CIsecurityauthorizationcost

Summary#

Every rule answers two questions, and each has its own place:

  • when — who is this rule for? A question about the caller alone: a role, a kind of principal, a fact about them.
  • where — which rows may they have? A question about the row (and, usually, how it relates to the caller).

The engine answers each rule's when once for the caller and never asks the where of a rule whose when does not hold. So a rule for customers costs a staff member nothing — as long as its when says it is for customers. A caller test written inside a where is a compile error, and the error prints the rule rewritten.

Signature#

allow <verbs> when <who> where <which rows>;    // a rule for some callers, over some rows
allow <verbs> when <who>;                       // every row, for those callers
allow <verbs> where <which rows>;               // those rows, for every SIGNED-IN caller (a signed-out one needs a `when`)

policy <Who> => <a question about `user` alone>;              // a role, a kind of principal, a fact about them
policy <Rows>(<Type> row) => <a question about the row and `user`>;

Description#

Give each kind of caller its own rule#

A desk where the team reads every case and a client reads the cases they are a party to is two rules, one per kind of caller. The when of each names who it is for:

[Role] enum DeskRole { Staff, Client }

[Principal] entity Person {
  [Required, MaxLength(200), Unique] string Email;
  security { allow read when IsAuthenticated; }
}

[RoleGrant] entity DeskGrant {
  Person Member;
  [Required] DeskRole Role;
  security { allow read when IsStaff; }
}

entity Case {
  [Required, MaxLength(200)] string Title;
  security {
    allow read, create, update when IsStaff;          // the team: every case
    allow read when IsClient where IsPartyTo(Id);     // a client: the cases they are a party to
  }
}

entity CaseParty {
  [Required] Case Case;
  [Required] Person Person;
  security { allow read when IsStaff; }
}

/// WHO: questions about the caller alone.
policy IsStaff => DeskGrant.Any(g => g.Member == user && g.Role == DeskRole.Staff);
policy IsClient => DeskGrant.Any(g => g.Member == user && g.Role == DeskRole.Client);

/// WHICH ROWS: a question about the row and the caller.
policy IsPartyTo(Guid c) => CaseParty.Any(p => p.Case.Id == c && p.Person == user);

For a member of the team, the client rule's when is false, so IsPartyTo is never asked of any row. For a client, the team's rule is never active. Each caller pays for exactly the rules that are about them.

A caller test in a where is refused, with the fix#

Folding the team into the client rule reads naturally and costs everyone: the rule is now live for every caller, and IsPartyTo is asked of every row for the team too. The compiler refuses any part of a where that never reads the row, and prints the rule split by caller:

allow read when IsAuthenticated where IsPartyTo(Id) || IsStaff;
// ERROR  security `where` on 'Case': `IsStaff` never reads the row — it asks only about the caller, so it belongs in
//        a `when`, not a `where`. … Write it as:
//   allow read when IsAuthenticated where IsPartyTo(Id);
//   allow read when IsAuthenticated && IsStaff;

The rewrite is exact — it grants exactly what the refused rule granted, to exactly the same callers:

you wroteit becomes
where A \|\| C (C asks only about the caller)two rules: where A and when C
where A && Cwhen C where A
where C (nothing reads the row)when C

A rule's existing when is kept in front of each rewritten rule, so tidy it afterwards when it is implied — IsStaff already implies IsAuthenticated, so when IsAuthenticated && IsStaff is simply when IsStaff.

One part is left alone: the constant false. It admits no row to anybody, so no caller pays for it and there is nothing to move. It is what a package's rule comes to while the app has not answered a question whose default is nobody ([[security-naming-a-policy#supplied-default]]) — where OwnsListAddress(Email) over virtual policy OwnsListAddress(string email) => false;. where true is still refused: it reads no row and admits every one.

when IsAnonymous || IsAuthenticated where … names nobody#

A when that admits every caller — none at all, IsAuthenticated, or IsAnonymous || IsAuthenticated — beside a where that asks the database about the caller is the same cost in a quieter form: the rule is live for everyone, so callers who already have their own grant ask its row question too. osy lint reports it (cost-row-rule-asked-of-every-caller, Should) and lists the policies about the caller that are in scope.

Name who the rule is for. If no policy says it yet, declare one about the caller alone:

whoa policy that says it
a rolepolicy IsClient => DeskGrant.Any(g => g.Member == user && g.Role == DeskRole.Client);
the signed-outIsAnonymous (built in)
everyone but the teampolicy IsOutsider => IsAnonymous \|\| IsAuthenticated && !IsStaff;
a fact about the callerpolicy HasACase => CaseParty.Any(p => p.Person == user);

⚠ The when must hold for every caller the where admits, or narrowing it takes access away. "Everyone but the team" is right only when the team's own rule already covers what the client rule gave them — a member of staff who is ALSO a client would otherwise lose a grant they had. When the team's grants do not cover it, name the fact instead (HasACase above): it is true for every caller IsPartyTo could admit.

where Owner == user is never a finding: comparing a column with the caller costs nothing, and it is the plainest way to say the owner sees their own.

Why the two places are not interchangeable#

A when is answered once per request, before any row is in hand — and when it reads nothing but the signed-in caller and the tables it looks in directly, its answer is kept between requests until one of those tables is written. A where is a question about each row: it becomes part of every query, and every write asks it of the row being written. The two are equally fresh (a grant committed a line ago is in force for both — see [[security-entity-security#same-freshness]]), and they are equally secure. What differs is who pays: a when costs the caller it is about, a where costs every caller its rule is live for.

Rules for different callers on a package's entity#

A package's entity that an app may only tighten ([Authority]) turns the package's grants into a floor. Rules that name different kinds of caller — allow read when IsStaff; beside allow read when IsClient where IsPartyTo(Id); — make a floor that is exact for each kind of caller: an app's grant can never let one caller in on another caller's rows. Write a package's rules one per caller, exactly as an app's.

See also#

Related

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

policy

Names an authorization rule once so every place that needs it can say the name. A policy is a boolean about the caller…

[Authority]

What an app that imports your entity may do to the `security { }` you declared on it. The default is `Narrowable` — the…

role grants (and the first admin)

A role is granted by an ordinary entity you mark `[RoleGrant]` — one that holds a reference to your `[Principal]` and a…