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'sCandidatesfor 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 createordeny deletea 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 callF, 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.UserManagementdeactivates a person throughOsysharp.Identity'sIsActive. - 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
virtualmethod, such as the conversations kit'sPartyKinds.Newmaking yourCustomer) 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] handler | a plain deny update X; a kit wrote | deny update X except via F; |
|---|---|---|
your app's code assigns X | refused | refused |
the kit's own code assigns X | written | refused |
code of a kit built on the kit assigns X | written | refused |
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;, nowhen, nowhere— 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 manifestuses 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 abool), 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 missingallow 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#
- [Authorize] (event) — who may raise an event
- subscribe — who may hold a work slot
- security { } — what the
security {}block gates - The WorkQueues kit — queues, who holds what, and the service levels promised on any item — the kit whose
deny update Assigneethis rule was built for - deny … except via — the deny that names the only function that may write, in a request and in every body