Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

deny … except via

deny update <Field> except via F, G; · deny create except via F; · deny delete except via F;

Names the ONLY functions whose own code may change something a deny withholds: `deny update UnitsLeft except via Draw;`. The deny steps aside for exactly that member, that operation and a write the named function itself makes, on the server — never for a write a client sends, and never for a helper the function calls. It is a floor wherever it is written, on a kit's entity and on an app's own: no `allow` opens a way past it and no other code makes the write — not a workflow step, the declaring kit's own included — on a saved row or on one made a moment ago in the same unit of work. Each named function belongs to the entity's owner, runs on the server, writes the member itself, and says who may call it with `[Authorize]`.

stable3 examples compiled by CIsecuritypackageskitsauthorization

Summary#

Some things a kit keeps should change in exactly one way. A stock count changes by drawing from it; a record of the draw is made by the draw itself. A deny says nobody may make that change — and except via names the kit's functions that ARE the way it happens:

  • the deny steps aside for the member it names, the operation it names, and a write the named function's own code makes — nothing more;
  • "inside the function" is decided by the server running it: a client's unit of work, a REST or MCP call, an import or a hand-made request is an ordinary write, judged against the deny with no exception;
  • a helper the function calls does not inherit it, and every OTHER write inside the function is judged as usual;
  • it is a floor: it is decided after every allow, whoever wrote it — a kit on its entity or an app on its own — so no grant opens a direct write past it, and no other function, class method, page action, workflow step or runas block makes the write. That includes the workflow of the kit that wrote the rule: its steps call the named function, like everything else.

Signature#

deny update <Field>, <Field> except via F, G;   // these fields change only inside F or G
deny create except via F;                       // a row of this entity is made only inside F
deny delete except via F;                       // …and removed only inside F

except via comes after any when/where and before message. It names functions, not strings — a misspelt name does not compile.

Description#

What does except via let through?#

One thing: a write the named function's OWN statements make, to the member (or the row) the rule is about, for the operation the rule is about. Everything the rest of the block says still applies — except via lifts a deny, it never grants. So the caller still needs a grant for the write (the kit's allow update when …), and the named function still needs its [Authorize] satisfied before its first statement runs.

the writejudged
Draw's own r.UnitsLeft = r.UnitsLeft - 1; (Draw is named)the deny steps aside
Replan's own old.Delete(); under deny delete except via Replan; beside allow delete where Owner == user;the deny steps aside, and the where still decides — the caller's own rows go, another person's are refused
the same assignment in a function Draw callsrefused — a helper is not the named function
Draw's own write to a field the rule does not namejudged by that field's rules, as anywhere
the same change sent by a browser, an API call, an importrefused — nothing a request carries is "inside Draw"
the same person assigning it in any other functionrefused
the same assignment on a row made a moment ago, not yet savedrefused — see A row that is not saved yet

Why is a client's write never "inside the function"?#

Because only the server decides it. When the server runs a function some except via names, the writes that function's own code makes are marked as its writes, as they are made; a write that arrives from anywhere else carries no mark, and there is nothing a request can send to make one. The rule is enforced on the server, at the write and again at the commit, whichever way the write came in.

From a page: show the field, and change it through the function#

A page cannot save its own write to such a field — its commit is refused by the deny — so a control that two-way binds one is a control whose edit goes nowhere. The compiler warns (TWO_WAY_BIND_TO_GUARDED_MEMBER), naming the field and the function to call. Show the value with a plain read, and make the change in the press:

[Server, Authorize(IsAuthenticated)]
public void Tick(Check k, bool on) { MarkChecked(k, on); UnitOfWork.Commit(); }

component Checklist() {
  live var checks = AllChecks();
  action Toggle(Check k) { Tick(k, !k.Checked); }
  render {
    foreach (var k in checks) {
      // `checked:` is a READ — the box shows the stored tick, and the press asks the function to change it
      Pressable(onClick: () => Toggle(k), role: UiRole.Checkbox, checked: k.Checked, label: k.Label) { Text(k.Label); }
    }
  }
}

A row handed to a function carries the page's edits to it ([[ui-data-mutation#handed-row]]) — but never an edit to a field the caller may not change itself. The function sees such a field as stored, so a Checkbox(value: k.Checked) pressed into Tick(k, k.Checked) still lands: MarkChecked finds the box unticked and ticks it. What the bound box cannot do is take its tick back when the call is refused, which is why the read is the shape to write.

What does the compiler refuse?#

Each of these would read as a decision and enforce something else, so each is an error naming the rule:

  • A named function that does not say who may call it. Writing past a deny is a privilege, so the function carries [Authorize(<policy>)] — in a kit, a policy the app answers (a policy MayDraw; the kit declares, or a virtual policy MayDraw => false; with a default) — or [Authorize(IsAuthenticated)] when any signed-in caller may and its body checks the rest. ([Authorize(IsAuthenticated)] is refused as redundant on every other function: there it says nothing a function does not already require.)
  • A name that is not a top-level function of the same owner — the same kit for a kit's entity, the app's own code for the app's — or one that does not itself make the write. A stale except via is an error, and a function whose helper makes the write excepts nothing.
  • A named function that could run on the client. A write the browser makes arrives as a client's unit of work, which never carries the exception — the function must make its writes on the server.
  • except via on an allow, on read, or in a partial that adds to another package's entity — which functions may write past that entity's denies is its own package's to say.

A field that changes in several ways — one function per way#

A deny names as many functions as there are ways the field changes. That is the shape for a status that moves through states: one function per move, each taking only what that move needs and making the assignment itself. A rule the moves share goes in a helper that CHECKS — answers a reason, or throws — and never writes, because a helper's write is not excepted:

deny update Status except via Ship, Deliver, SendBack;

string NotIn(Parcel parcel, ParcelStatus expected) =>
  parcel.Status == expected ? "" : $"{parcel.Reference} is {parcel.Status.Label}.";   // checks, never writes

[Server, Authorize(IsAuthenticated)]
void Ship(Parcel parcel, string tracking) {
  var why = NotIn(parcel, ParcelStatus.Packed);
  if (why != "") { throw new Exception(why); }
  parcel.Tracking = tracking;
  parcel.Status = ParcelStatus.Shipped;                                               // its own write: excepted
}

Not one Advance(string to, …) that every move goes through: it takes the state as text, and its callers pad the arguments the other moves need with "". A name whose helper makes the write is refused, and the refusal names the helper and shows this shape. The whole pattern, compiled with its tests, and the workflow that owns such a field outright, is An entity's lifecycle — a workflow that tracks its status, or one function per move.

On your app's own entity#

An app writes it the same way, naming its own functions, and the compiler vets them exactly as it vets a kit's. A kit's function can never be named for an app's entity, nor an app's for a kit's.

It holds the same way too. allow create when IsAuthenticated; beside deny create except via MakeJar; does not let another of your functions make a jar: the deny is decided after every grant, so MakeJar is the only way one is made — from a page, from a class method, from a workflow step, inside a runas block, in a function with a unit of work of its own. The grant still matters: it says who may make one THROUGH MakeJar.

A row that is not saved yet#

A row a function has just made is not saved until the unit of work commits, and until then other code in the same request can reach it. The deny holds there as it does on a saved row:

the write, on a row made earlier in the same unit of workjudged
the code that made the row, filling it in (its initializer, its own later lines)part of the create
a function named for the field (Fill(jar) after MakeJar(…))the deny steps aside
any other code writing a field an except via names — the caller that got the row back, a helperrefused, at the write
any other code writing a field no except via namespart of the create — what may be put on a new row is the create rules' to say

So var jar = MakeJar("rye"); jar.Level = 77; is refused exactly as it would be a request later. A deny update … where … except via F cannot be decided on a row with nothing stored yet, and is left to the create's rules.

A kit's plain deny update Field; on a row that is not saved yet#

A kit also writes denies that name no function and carry no condition — deny update Member; on a membership means nobody re-points it, ever. That line holds on the row the kit's function has just handed back, too:

the write, on a row a kit's function made earlier in the same unit of workjudged
the kit's own code — the function that made the row, and any other function of the kit (or of a kit built on it) that goes on filling itpart of the create
your app's code writing a field the kit marks deny update Field; — the caller that got the row back, a helper, a class method, a workflow steprefused, at the write
your app's code writing a field under a CONDITIONAL deny (deny update Text when !IsStaff)part of the create
your app's code writing a field no deny coverspart of the create

The line between the second and third rows is what the rule says. A deny with no when and no where says the field does not change once the row exists, whoever asks — so it does not matter that the row is a moment old. A deny with a condition says WHO may change the row, and the person filling a new row is the person who made it: a visitor who may create a message and not edit one still has it filled in by a helper before it is saved, and the rule is asked of them from the commit on.

What the kit's function put on the row is the kit's decision. If your code needs a different value there, pass it to the kit's function; if the function takes none, the field is not yours to set.

Your app's own plain deny update Field; on its own entity is not asked on a pending row — every function that could write it is yours. Name the function (deny update Field except via F;) when one of them must be the only way.

How does a test make the row?#

A test body runs as its [runas(…)] person and meets the deny like any caller: make the row through the named function. A [TestFixture] is the system and seeds past every security { } block, this one included — put the rows that are the test's world there. See Fixtures: built once, cloned for every test, and built on each other.

⚠ Not a grant instead. "Let only the desk change it" written as allow update Level when IsDesk; lets the desk's BROWSER write Level to anything, directly. except via is what makes the function the only way it changes — the check the function makes is then the check every change passes.

Does a workflow body follow the same rule?#

Yes, and it is one rule for every body: the exception is the same triple in a workflow body and in a request. A write Draw makes passes Draw's except via wherever Draw was called from; nothing else in a body does.

A body writes as the system, and for a kit's PLAIN denies that has an exception — the kit's own workflow, and its functions called from your body, write past them (see What a workflow body may write). A deny that names a function has none:

the write to a member deny update UnitsLeft except via Draw; guardsjudged
a step of your app's workflow assigning itrefused
a step of the kit's OWN workflow assigning itrefused
a function of the kit that is not Draw, called from either workflowrefused
a step of a kit built on the kit assigning itrefused
an [On] handler — the app's or the kit's — assigning itrefused
Draw, called from any of themthe deny steps aside

So a kit that changes a guarded member on a timer writes a function for it and names it: the rule then lists every way the member changes, and a reader of the security { } block sees all of them.

deny update Remaining, Expired except via Spend, ExpirePack;

public workflow PackLife {
  …
  state Expiring {
    Expire { Within = UntilExpiry(this.Item); }
    on Expire { ExpirePack(this.Item); goto Ended; }   // not `this.Item.Remaining = 0;` — the step is not the function
  }
}

/// The pack's own clock is the only caller; a workflow body does not ask `[Authorize]`.
[Server, Authorize(ManagesPacks)]
void ExpirePack(Pack pack) { pack.Expired = pack.Remaining; pack.Remaining = 0; }

A statement over many rows#

A set-based update or delete (one statement over every matching row) carries no function's mark, so a deny it meets does not step aside. Write the rows one at a time inside the named function.

Examples#

A kit whose stock changes only by drawing it#

The kit's side — the deny, the policy the app answers, and the functions it names:

namespace Acme.Reagents;

/// Who may draw stock — the app answers.
policy DrawsReagents;

public entity Reagent {
  [Required, MaxLength(80)] string Label;
  int UnitsLeft = 0;
  security {
    allow read, create, update when IsAuthenticated;
    deny update UnitsLeft except via Draw;
  }
}

public entity Withdrawal {
  [Required] Reagent Reagent;
  security {
    allow read, create when IsAuthenticated;
    deny create except via Draw;
  }
}

[Server, Authorize(DrawsReagents)]
public void Draw(Reagent r) {
  r.UnitsLeft = r.UnitsLeft - 1;      // passes: Draw's own write, the member it is named for
  new Withdrawal { Reagent = r };     // passes: Draw's own create
}

An app answering a kit's policy#

The UserAccounts kit lets a desk move an account to a new address — ChangeAddressFor is the only way a change is made (deny create except via ChangeAddressFor), and it runs only for the people the app names. The app's whole part is the policy:

app Desk {
  use Osysharp.Ui;
  use Osysharp.Identity;
  use Osysharp.Mail;
  use Osysharp.UserAccounts;
  use Osysharp.EmailTemplates;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.UserAccounts;

[Role] enum AppRole { Authenticator, Agent, Lead }

policy IsLead => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Lead);
policy ChangesAccountsFor => IsLead;

/// What the lead's screen calls for somebody whose old address is gone.
void MoveForThem(User account, string newAddress) { ChangeAddressFor(account, newAddress, "ID checked at the desk"); }

[Route("/")] component Home() { render { Text("The desk"); } }

An app's own entity, changed only by its own function#

[Principal] entity Cook {
  [Unique, MaxLength(80)] string Name;
  security { allow read when IsAuthenticated; }
}

entity Jar {
  [Required, MaxLength(80)] string Label;
  int Level = 0;
  security {
    allow read, create, update when IsAuthenticated;
    deny create except via MakeJar;
    deny update Level except via Fill;
  }
}

/// Any signed-in person may make and fill a jar — said, because each writes past a deny.
[Server, Authorize(IsAuthenticated)]
Jar MakeJar(string label) { return new Jar { Label = label }; }

[Server, Authorize(IsAuthenticated)]
void Fill(Jar jar) { jar.Level = 10; }

void Shortcut(string label) { new Jar { Label = label }; }                 // not MakeJar
void MakeAndOverfill(string label) { var jar = MakeJar(label); jar.Level = 77; }   // not Fill
void MakeAndFill(string label) { var jar = MakeJar(label); Fill(jar); }

[TestFixture]
void Pantry() {
  new Cook { Name = "Ida" };
  new Jar { Label = "oats", Level = 3 };      // the fixture is the system: it seeds past the deny
}

principal Ida => Cook.Single(c => c.Name == "Ida");

[Test(Pantry)]
[runas(Ida)]
void Only_the_named_functions_make_and_fill_a_jar() {
  Assert.Denied(() => Shortcut("rye"));              // the grant beside the deny does not open it
  Assert.Denied(() => MakeAndOverfill("rye"));       // …nor does the row being a moment old
  Assert.Equal(0, Jar.Count(j => j.Label == "rye"));
  MakeAndFill("barley");
  Assert.Equal(10, Jar.Single(j => j.Label == "barley").Level);
  Assert.Equal(3, Jar.Single(j => j.Label == "oats").Level);
}

A kit's plain deny, on the row its function just made#

The Organizations kit makes a membership with Join and writes deny update Member; — a membership is one person in one organisation, never re-pointed. The app below holds the row Join returned and tries to put somebody else on it before anything is saved:

app Portal {
  use Osysharp.Identity;
  use Osysharp.Organizations;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Organizations;

[Role] enum PortalRole { Authenticator, Staff }

policy IsStaff => RoleGrant.Any(g => g.Member == user && g.Role == PortalRole.Staff);
/// The kit's question, answered by the app.
policy ManagesOrganizations => IsStaff;

entity Person : Principal {
  security { allow read when IsAuthenticated || IsAuthenticator; }
}

entity Company : Organization {
  security { allow read, create, update when IsStaff; }
}

/// The kit's `Join` makes the membership and hands it back…
void Enrol(Company company, Person who) { Join(company, who); }
/// …and this tries to finish the job its own way.
void EnrolThenSwap(Company company, Person who, Person other) {
  var membership = Join(company, who);
  membership.Member = other;            // refused: the kit wrote `deny update Member;`
}

[TestFixture]
void Portal() {
  var sam = new Person { Email = "[email protected]", DisplayName = "Sam" };
  new RoleGrant { Member = sam, Role = PortalRole.Staff };
  new Person { Email = "[email protected]", DisplayName = "Ada" };
  new Person { Email = "[email protected]", DisplayName = "Bo" };
  new Company { Name = "Acme" };
}

principal Sam => Person.Single(p => p.Email == "[email protected]");

[Test(Portal)]
[runas(Sam)]
void A_membership_the_kit_made_is_not_repointed_before_it_is_saved() {
  var acme = Company.Single(c => c.Name == "Acme");
  var ada = Person.Single(p => p.Email == "[email protected]");
  var bo = Person.Single(p => p.Email == "[email protected]");
  Assert.Denied(() => EnrolThenSwap(acme, ada, bo));
  Assert.Equal(0, Membership.Count(m => m.Organization == acme));
  Enrol(acme, ada);
  UnitOfWork.Commit();
  Assert.True(Membership.Any(m => m.Organization == acme && m.Member == ada));
}

See also#

Related

allow read via

Names the functions whose OWN reads of the entity see every row — past its read rules and its field masks — to compute…

Fixtures: built once, cloned for every test, and built on each other

A [TestFixture] is built once per run, and every test that names it runs on its own copy of what it left — rows, files…

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…

Security.Authorize — does THIS person satisfy a policy

`Security.Authorize(person, Policy)` answers whether a given person satisfies a declared policy — evaluated exactly as…

[Authority]

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

What a workflow body may write

A workflow body runs on a system data context, so its writes are NOT gated by the app's `security {}` blocks. The…

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…

entity Sub : Base

Derives one entity from another. The subtype is its own type with its own name, its own security and its own workflows…