Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

What a workflow body may write

state X { on Event { this.Item.Anything = …; } } — a body writes as the SYSTEM, not as the caller

A workflow body runs on a system data context, so its writes are NOT gated by the app's `security {}` blocks. The person who raised the event is authorized to RAISE it — `[Authorize]` and a slot's `Candidates` decide that — and everything the transition then writes lands regardless of what that person could have written themselves. The one exception is a `deny` a KIT wrote: inside a body, only that kit's own code (and a kit built on it) writes past it — and a function the deny names in `except via` writes past it everywhere, in a body and a request alike.

stable2 examples compiled by CIworkflowsecurity

Summary#

A workflow body writes as the system, not as the caller. The entity's security {} block gates what a PERSON may do directly; it does not gate what a transition does once an event has been legitimately raised — with one exception: a deny that a kit wrote binds every line of a workflow body except the kit's own code — its own workflow, its own functions when your body calls them, and a kit built on it. Outside a body — in a page action, an API call — a kit's function writes as its caller, and only a function the deny names in except via writes past it (deny … except via).

Signature#

state Open {
  on Close {
    this.Item.Note = "closed by the workflow";   // lands even if NO rule grants `update` to anyone
    goto Closed;
  }
}

Description#

Where the authorization actually happens#

Two different questions, and only the first one is about the person:

  • May they raise this event? [Authorize(principal => …)] on the event, and a slot's Candidates for who may hold the work. See [Authorize] (event).
  • May the body write this row? Not asked. The engine opens a system data context for the run, and a system context is defined as one that opts out of data security.

So a transition is trusted code, in the same sense a server function's own bookkeeping is. Put the gate on the EVENT, where the person is.

Why it is built that way#

A run outlives the request that started it. A milestone that expires at 2am, a deadline, a retry after a service came back — these resume with no acting principal at all, and a rule written allow update where Owner == user has no user to compare against. If a body were gated by the caller's rules, the same transition would succeed or fail depending on who happened to trigger it, and a timer-driven one could never succeed.

A kit's deny binds every workflow but the kit's own code#

A kit says what only IT may change with a deny. The WorkQueues kit's WorkItem carries deny update Assignee — who holds a piece of work changes only inside the kit's own run, where every change is recorded with its reason. If a workflow body's system authority were total, your app's own workflow over the same row could assign it anyway and leave no trail.

So the system authority stops at a package's deny, and what decides is WHOSE CODE makes the write:

  • Your app's workflow writes past your app's own rules, as above, but NOT past a deny update, deny create or deny delete a kit wrote. The write is refused, and the refusal names the kit.
  • Nor past a deny … except via F, whoever wrote it and whoever's body it is. That line names the only code that may make the write, and a workflow body is not it: your app's body, the kit's own body and the body of a kit built on it all call F, like everything else (deny … except via). See the one deny no body writes past.
  • The kit's own workflow writes past the kit's other denies — a plain deny update AssigneeId; is what its run is for.
  • The kit's own function writes past those too when your app's workflow body calls it. The write happens inside the kit's code, so it is the kit's: the kit decides what its function writes, and your app cannot widen it. ⚠ That is the BODY's authority, not the function's: called from a page action or an API call, the same function writes as its caller and is held to the deny like any code. A kit that wants one of its functions to change a denied member from a request names it — deny update UnitsLeft except via Draw; — and that exception is the same in a body and a request: the named function's own write to the named member, decided by the server.
  • A kit built on the kit — one whose manifest uses it — writes past the kit's plain denies as the kit does. It is that kit's extension: Osysharp.UserManagement deactivates a person through Osysharp.Identity's IsActive.
  • Your app's own code does not: your workflow body, and every function it calls that is not the kit's. App code the KIT's run calls through the kit's own extension point (an override of a kit class's virtual method, such as the conversations kit's PartyKinds.New making your Customer) is part of that run, and runs as it does.
  • A conditioned deny (deny update Account when !IsAuthenticator) is evaluated for the person who raised the event. A body that a timer drove has nobody, so the deny applies.
  • Only a deny the kit wrote counts. What a kit merely did not grant still governs your app's direct writes, and does not fence a body.
  • Reads are judged when you compile, not when the body runs — see kit read denies.

Change a kit-owned field the kit's way — raise the kit's workflow event, or call the kit's own function that makes the change, so the kit's code does the write.

The one deny no body writes past#

deny update Level except via Fill; says which CODE changes Level. A body's system authority is an answer to "who is asking", so it does not apply: a step that assigns the field is refused, and a step that calls Fill is not — in your app's workflow over your own entity, in a kit's workflow over the kit's own entity, and in the workflow of a kit built on it.

in a workflow step or an [On] handlera plain deny update X; a kit wrotedeny update X except via F;
your app's code assigns Xrefusedrefused
the kit's own code assigns Xwrittenrefused
code of a kit built on the kit assigns Xwrittenrefused
anybody calls F, and F assigns X—written

[Authorize] on F is not asked in a body — a body has no caller — so a kit's clock or an event handler calls the named function freely; the attribute keeps saying who may call it from a request.

What may my code read of a kit's denied fields?#

A body reads as the system, so at run time it would read past every deny read. Two kinds of kit line are held anyway, when your app compiles:

  • A broad deny — deny read SealedCode;, no when, no where — means nobody but the kit reads it. Your app's code that names the field does not compile, in a function and in a workflow body alike. Call the kit's function that does the work instead; a kit built on the kit (one whose manifest uses it) reads it like the kit. A [Test] may name it: a test runs as a person, so it reads the masked value, and asserting that mask is how a kit proves its line.
  • A credential mask — deny read PasswordHash when !IsAuthenticator; — keeps the column out of everything but the sign-in. You may still CHECK it (Security.VerifyPassword(password, u.PasswordHash) answers a bool), so your own sign-in method works. Logging it, mailing it, copying it to another field or returning it is refused, the same way a secret is.

A deny that depends on who is reading (deny read Balance when !IsShopStaff, deny read … where Owner != user) is not held in a body: its answer would change with whoever raised the event, and a timer raises it for nobody. It keeps governing what people read.

on Paid {
  // Mail.Send(to, Crypto.Decrypt(card.SealedCode!));   // refused: the shop kit wrote `deny read SealedCode;`
  DeliverGiftCard(card);                                // the kit's own function mails it, once
}
state Escalated {
  enter {
    // this.Item.AssigneeId = lead;          // refused: the kit wrote `deny update AssigneeId`
    WorkHandling.RaiseTake(this.Item, lead);  // the kit's own run writes it, and records why
    Reassign(this.Item, lead);                // …or a function the kit ships: its code, so its write
  }
}

What this means for your model#

  • Do not rely on your app's security {} to stop your own workflow. If a field must never change once a run owns it, that is a guard in the body or an [Immutable] on the member — not a missing allow update. (A kit that ships the field can stop you: see kit denies. So can naming the one function that may write it — deny update Status except via Close; holds in your own workflow too.)
  • A read from a page is still gated. This is about the BODY. What the person then sees on screen goes through the ordinary read rules, unchanged.
  • [Authorize] is the real perimeter. An event anybody may raise is an event anybody may cause every write in its transition to happen.

Examples#

A whole app, compiled: Ticket grants read and create and no update to anyone, and the transition writes Note anyway.

using Osysharp.Workflow;

enum TicketState { Open, Closed }

entity Ticket {
  [MaxLength(80)] string Title;
  TicketState Status = TicketState.Open;
  [MaxLength(200)] string Note = "";
  security { allow read, create when IsAnonymous || IsAuthenticated; }   // no `update`, to anyone
}

workflow TicketFlow {
  Tracks  = Ticket.Status;
  Initial = Open;

  event Close();

  state Open {
    on Close {
      this.Item.Note = "closed by the workflow";   // lands: a body is not gated by the block above
      goto Closed;
    }
  }
  state Closed { }
}

void CloseIt(Ticket t) { TicketFlow.RaiseClose(t); }

A deny that names a function holds in the same place — the step calls the function:

using Osysharp.Workflow;

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

enum BatchState { Resting, Done }

entity Batch {
  [Required, MaxLength(80)] string Label;
  BatchState Status;
  int Turns = 0;
  security {
    allow read, create, update when IsAuthenticated;
    deny update Turns except via Turn;
  }
}

[Server, Authorize(IsAuthenticated)]
void Turn(Batch batch) { batch.Turns = batch.Turns + 1; }

workflow BatchFlow {
  Tracks    = Batch.Status;
  Autostart = true;
  Initial   = Resting;

  event Nudge();
  event Shortcut();
  event Bake();

  state Resting {
    on Nudge { Turn(this.Item); }                 // the named function: written
    on Shortcut { this.Item.Turns = 40; }         // the step itself: refused
    on Bake { goto Done; }
  }
  terminal success Done { }
}

void NudgeIt(Batch b) { BatchFlow.RaiseNudge(b); }
void ShortcutIt(Batch b) { BatchFlow.RaiseShortcut(b); }

[TestFixture]
void Bakery() {
  new Cook { Name = "Ida" };
  new Batch { Label = "rye" };
}

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

[Test(Bakery)]
[runas(Ida)]
void A_step_changes_the_field_only_through_the_named_function() {
  var rye = Batch.Single(b => b.Label == "rye");
  Workflow.Settle(rye);
  Assert.Denied(() => ShortcutIt(rye));
  Assert.Equal(0, Batch.Single(b => b.Label == "rye").Turns);
  NudgeIt(rye);
  Assert.Equal(1, Batch.Single(b => b.Label == "rye").Turns);
}

Gate the EVENT, which is where the person is:

[Authorize(principal => principal == this.Item.Owner)]
event Close();

See also#

Related

[Authorize] (event)

Gates WHO may raise a workflow event. `[Authorize]` on an event is a `principal => bool` predicate over the acting…

subscribe

Declares that a workflow state waits on an event, and configures the wait — who may hold it, who may hand it on…

The security model

How authorization works in Osy#, end to end. Everything is denied until you grant it; a grant is compiled into every…

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…

deny … except via

Names the ONLY functions whose own code may change something a deny withholds: `deny update UnitsLeft except via…