Summary#
Some work is done by nobody in particular: a nightly run that settles invoices, a machine that sorts parcels, an
assistant that files what it was asked to. That work still needs to be somebody to your security { } rules — with
rights you chose, and a name on what it changed.
A declared actor is that somebody. You declare it as a class; the platform makes its account and stamps its roles; and a block of code runs as it:
[Actor(Roles = [DepotRole.Dispatcher])]
public class SortingMachine {
public static Account Principal => new Account { Email = "[email protected]" };
}
runas (SortingMachine) { parcel.Bay = "B4"; } // the machine's rights, the machine's name on the changeSignature#
[Actor] // an actor that holds no role
[Actor(Roles = [AppRole.Finance, …])] // …or the roles its account holds — members of your [Role] enum
public class Name {
public static <Principal> Principal => new <Principal> { … }; // how its account row is made — required
}
runas (Name) { … } // run the block as the actor
runas (Name, onBehalfOf: person) { … } // …for a person, and never past their own rights — see security-on-behalf-of
runas (Name, scope: row) { … } // …naming the piece of work on every change the block records
Actor.Of<Name>() → <Principal> // the actor's own account row, as a value
Actor.Of<T>() → <Principal> // …of the class a type parameter names: `Principal RowOf<T>() => Actor.Of<T>();`
Actor.Of(type) → <Principal> // …of a class held as a value: `Actor.Of(typeof(Name))`, `Actor.Of(agent.GetType())`
Actor.From(row) → <Principal> // a row you hold, as an actor's — refused unless it IS a declared actor's
runas (Actor.From(task.Actor)) { … } // …which is how generic code runs as an actor it cannot nameDescription#
How is the actor's account made?#
By the platform, once. Principal builds one row of your [Principal] type; the platform runs it when the app is
deployed (and on first use in a database that has none yet, such as a test's), keeps the row under an identity of its
own, and gives it a grant for each role in Roles. A later deploy brings the grants back to exactly what the
declaration says, so removing a role from the source removes it from the actor.
Nothing else makes or widens an actor: the roles are the declaration's, never a caller's choice, and no function a request can call builds the account.
Actor.Of<Name>() answers that row wherever you need it as a value — the holder of a piece of work, the owner a query
compares with. You do not need the right to read the row to name it.
Generic code names the class by its type parameter — Principal RowOf<T>() => Actor.Of<T>();, which is how a kit
answers the account of an actor your app declares. Which class T is arrives with the call; a class that is not a
declared actor is refused when it runs (Apprentice is not a declared actor of this app).
Code that holds the class as a value — a Type in a variable, a list of them, agent.GetType() — writes
Actor.Of(type): the same account, with the same refusal for a type that is not a declared actor, and it answers
before the actor has ever acted. Actor.Of(typeof(Stapler)), written in place, is refused where it is written. A
Type is only a name: the platform looks it up among the actors your app declares, so no value code can build makes
an actor that was not declared.
What may the actor do?#
What its roles allow under your ordinary security { } blocks, and nothing more. There is no special access: an actor
holding Dispatcher is refused wherever a dispatcher would be. An admin calling a function that runs as the actor
gets the actor's rights inside the block, not their own — and gets their own back when it ends.
How does code run as it?#
With runas (Name) { … }, in a function, an event handler or a workflow body. Inside the block every read is the
actor's, every write is judged as the actor, every [Authorize] is asked of the actor, and Session.CurrentUser is
the actor's account.
In a workflow body this is how one step answers to rules at all: a body otherwise writes with the app's own
authority (What a workflow body may write), and a runas block inside it is held to the actor's roles instead.
What about a row I already hold?#
It is read as the actor too. "Every read is the actor's" covers a member read off a variable from outside the block, whether or not you had read that member a line earlier:
parcel was read by the caller, then… | answer |
|---|---|
| a member the actor may not read, inside the block | hidden — it reads as the member's empty value ("", 0, null), the same as for anyone it is hidden from |
| a member only the ACTOR may read, inside the block | its value |
a reference followed inside the block (parcel.At.Keycode) | the row it leads to, as the actor reads it |
| a row the actor may not read at all | every member hidden |
| the same members after the block | what the caller may read, as before — nothing the actor saw is left on the row |
So a value the block needs from the caller's side is taken before it, into a local — a local is a value, not a read:
var contents = parcel.Contents; // the caller may read this; the machine may not
runas (SortingMachine) {
parcel.Bay = BayFor(contents); // the value the caller read — `parcel.Contents` here would be ""
}Nothing says so when it runs: it is an ordinary hidden read. With onBehalfOf: a member is hidden when either
may not read it ([[security-on-behalf-of#reads]]).
The compiler says so where it can see it. osy validate, the editor and the compile warn
(RUNAS_READS_A_HELD_ROWS_HIDDEN_MEMBER) on a read inside the block of a member that carries a deny read, off a
parameter or a local from outside the block:
`parcel.Contents` is read inside `runas (SortingMachine)`, and `Parcel` has a `deny read Contents` — … Read it
before the block and use the local: `var contents = parcel.Contents;` above `runas`.| the warning sees | it does not see |
|---|---|
row.Member where row is a parameter, or a local whose type is written or plain from new T { } or T.Single(…) / First / FirstOrDefault / SingleOrDefault / Find | a row reached another way — a class's field, a call's result |
a deny read Member … on any declaration of the entity, with or without a condition | whether that condition holds for this actor: that is decided when it runs, so an actor who MAY read the member is warned too |
an entity-wide deny read, or a row the actor may not read at all |
A row the block reads itself is the actor's own read and is never warned; nor is code in a test, where reading a
member as somebody it is hidden from is how hidden is proved. [SuppressWarning("RUNAS_READS_A_HELD_ROWS_HIDDEN_MEMBER")]
on the function records a read that is meant.
What happens to what the block writes?#
A runas block is a unit of work of its own:
| when | what happens |
|---|---|
| the block starts | what you have pending is committed first, as you |
| the block ends | what it wrote is committed, as the actor — its [CreatedBy] / [ModifiedBy] stamps and its change-trail entries name the actor |
| the block throws | what it wrote is discarded |
So nothing the actor did is ever saved later under somebody else's name. For the same reason a block cannot wait on a person, a client or another run: do the waiting outside it.
And in a task, beside other code?#
The same, with one difference. A task — a call that was not awaited where it was made (async / await and Task) — runs beside its caller, and a block inside it runs beside everything else that is running: the caller, and every other task. So that none of them is ever read, written or recorded as the actor, the block works in a unit of work that is separate from its caller's:
a block in a call you await where you make it | a block in a task | |
|---|---|---|
| what you have pending when it starts | committed first, as you | left pending — a task may not commit its caller's work — and not there for the block |
| what the block reads | what is stored, as the actor | the same |
| what the block wrote, when it ends | committed, as the actor | the same |
| the caller, and the tasks beside it | — | go on reading and writing as who they are, the whole time |
So await Task.WhenAll(robotA.Work(…), robotB.Work(…)) runs two actors at once, each as itself. Three things follow:
- A row you made and have not saved is not there for the block. Used inside it, the block is refused and says
so: save it before you start the task (
UnitOfWork.Commit()), or make the row inside the block. In a workflow body nothing needs saying — what the body has written is saved before a task starts (Tasks that wait (Task.WhenAll across days)). - A row from outside is still the actor's inside (above), and yours again after: a row the block read and handed back — returned, kept in a variable — is read as whoever uses it next.
- Outside its block a task still writes nothing ([[function-async-await#writes]]). The block is where a task's writes go.
A block entered on your own thread while tasks you started are still running is kept apart from them the same way; there your pending work is committed first, as usual. And a task started inside a block is the block's own work: the block does not end until it has finished.
The change trail records every change an actor makes, whatever the app otherwise audits, so an app that has an actor
— its own, or one a package it uses declares — includes the trail: use Osysharp.Observability; (audit read access (app.Audit)).
An app that does not is refused when it is compiled, naming the actor.
Can a package declare an actor?#
Yes: a kit declares the identity its own code runs as, and its functions say runas (Name) { … } like any other
code. The roles it names are the app's, written in the package's role vocabulary (role — a package's name for the app's own roles):
[Actor(Roles = [role.Sweeper])]
public class Sweeper { public static Member Principal => new Member { Email = "[email protected]" }; }An app that uses the kit and has no Sweeper in its [Role] enum is refused when it is compiled. The actor then
holds exactly that role in the app, whoever calls the kit's function.
What will the compiler refuse?#
Outside a test, runas runs as a declared actor and nothing else.
| you write | answer |
|---|---|
runas (SortingMachine) — a class marked [Actor] | runs as the actor |
runas (Actor.Of<SortingMachine>()) | the same |
Actor.Of(typeof(SortingMachine)), Actor.Of(type) | the actor's account; a Type that names no declared actor is refused when it runs |
Actor.Of("SortingMachine") — anything but a Type | compile error saying what to write |
runas (Actor.From(row)) | compiles; refused when it runs unless row is a declared actor's own account |
runas (Account.Single(…)) — any other row | compile error: a row is never run as because code can read it |
runas (Stapler) — a class that is not an actor | compile error naming the class |
runas (AuthBootstrap), runas (new Visitor { … }) | compile error: test-only forms (runas) |
An [Actor] that cannot be one is refused where it is written: a role that is not in your [Role] enum, a missing
Principal, a Principal that builds something other than your [Principal] type, or the attribute on an entity.
So is an app that has an actor and does not keep the change trail (above).
Examples#
using Osysharp.Observability;
[Role] enum DepotRole { Dispatcher, Courier }
[Principal] entity Account {
[Unique, MaxLength(200)] string Email;
security { allow read when IsAuthenticated; }
}
[RoleGrant] entity RoleGrant {
[Required] Account Holder;
[Required] DepotRole Level;
security { allow read when IsAuthenticated; }
}
policy IsDispatcher => RoleGrant.Any(g => g.Holder == user && g.Level == DepotRole.Dispatcher);
/// The sorting machine: it holds the dispatcher's role, and nothing else.
[Actor(Roles = [DepotRole.Dispatcher])]
public class SortingMachine {
public static Account Principal => new Account { Email = "[email protected]" };
}
entity Parcel {
[Required, MaxLength(40)] string Code;
[MaxLength(40)] string Bay = "";
[ModifiedBy] Guid? MovedBy;
security {
allow read when IsAuthenticated;
allow create, update when IsDispatcher; // a courier may not move a parcel
}
}
/// Moving it by hand: only a dispatcher's to do.
void MoveByHand(Parcel parcel, string bay) { parcel.Bay = bay; UnitOfWork.Commit(); }
/// Anybody signed in may ask for a parcel to be sorted; the machine does the moving.
void Sort(Parcel parcel, string bay) {
runas (SortingMachine) { parcel.Bay = bay; }
}
[TestFixture]
void Depot() {
var cleo = new Account { Email = "[email protected]" };
new RoleGrant { Holder = cleo, Level = DepotRole.Courier };
new Parcel { Code = "P-1" };
}
principal Cleo => Account.Single(a => a.Email == "[email protected]");
[Test(Depot)]
[runas(Cleo)]
void a_courier_cannot_move_a_parcel_but_the_machine_sorts_it_for_her() {
var parcel = Parcel.Single(p => p.Code == "P-1");
Assert.Denied(() => MoveByHand(parcel, "B4"));
UnitOfWork.Discard();
Sort(parcel, "B4");
var sorted = Parcel.Single(p => p.Code == "P-1");
Assert.Equal("B4", sorted.Bay);
Assert.Equal(Actor.Of<SortingMachine>().Id, sorted.MovedBy); // the machine's change, not Cleo's
Type machine = typeof(SortingMachine);
Assert.Equal(sorted.MovedBy, Actor.Of(machine).Id); // the same account, from the class as a value
}A row the caller holds is read as the actor inside the block, and as the caller again after it — in both directions:
using Osysharp.Observability;
[Role] enum DepotRole { Dispatcher, Clerk }
[Principal] entity Account {
[Unique, MaxLength(200)] string Email;
security { allow read when IsAuthenticated; }
}
[RoleGrant] entity RoleGrant {
[Required] Account Holder;
[Required] DepotRole Level;
security { allow read when IsAuthenticated; }
}
policy IsDispatcher => RoleGrant.Any(g => g.Holder == user && g.Level == DepotRole.Dispatcher);
policy IsClerk => RoleGrant.Any(g => g.Holder == user && g.Level == DepotRole.Clerk);
[Actor(Roles = [DepotRole.Dispatcher])]
public class SortingMachine {
public static Account Principal => new Account { Email = "[email protected]" };
}
entity Depot {
[MaxLength(40)] string Name = "";
[MaxLength(40)] string Keycode = "";
security {
allow read when IsAuthenticated;
deny read Keycode when !IsClerk; // a clerk's to read
}
}
entity Parcel {
[Required, MaxLength(40)] string Code;
[MaxLength(80)] string Contents = "";
[MaxLength(80)] string Route = "";
Depot? At;
security {
allow read when IsAuthenticated;
allow create, update when IsDispatcher || IsClerk;
deny read Contents when !IsClerk; // a clerk's to read
deny read Route when !IsDispatcher; // a dispatcher's — so the machine's — to read
}
}
/// A slip a clerk files; nobody else reads one.
entity Slip {
[MaxLength(40)] string Line = "";
security { allow read, create when IsClerk; }
}
public class Seen {
public string Before = "";
public string Inside = "";
public string Queried = "";
public string After = "";
}
/// The caller hands over a row it has already read from.
Seen ReadHeld(Parcel parcel) {
var seen = new Seen { };
seen.Before = parcel.Contents + "|" + parcel.Route + "|" + parcel.At!.Keycode;
runas (SortingMachine) {
seen.Inside = parcel.Contents + "|" + parcel.Route + "|" + parcel.At!.Keycode;
var again = Parcel.Single(p => p.Code == parcel.Code);
seen.Queried = again.Contents + "|" + again.Route;
}
seen.After = parcel.Contents + "|" + parcel.Route + "|" + parcel.At!.Keycode;
return seen;
}
/// The block is the first to read the row's members at all.
Seen ReadFirstInside(Parcel parcel) {
var seen = new Seen { };
runas (SortingMachine) { seen.Inside = parcel.Contents + "|" + parcel.Route + "|" + parcel.At!.Keycode; }
seen.After = parcel.Contents + "|" + parcel.Route + "|" + parcel.At!.Keycode;
return seen;
}
Seen ReadSlip(Slip slip) {
var seen = new Seen { };
seen.Before = slip.Line;
runas (SortingMachine) { seen.Inside = slip.Line + "|" + Slip.Count(); }
seen.After = slip.Line;
return seen;
}
/// For the caller: hidden from either is hidden.
Seen ReadHeldFor(Parcel parcel, Account person) {
var seen = new Seen { };
runas (SortingMachine, onBehalfOf: person) { seen.Inside = parcel.Contents + "|" + parcel.Route; }
seen.After = parcel.Contents + "|" + parcel.Route;
return seen;
}
/// What the caller may read and the actor may not is taken before the block.
string CarriedIn(Parcel parcel) {
var contents = parcel.Contents;
var carried = "";
runas (SortingMachine) { carried = contents + "|" + parcel.Contents; }
return carried;
}
[TestFixture]
void World() {
var cleo = new Account { Email = "[email protected]" };
new RoleGrant { Holder = cleo, Level = DepotRole.Clerk };
var north = new Depot { Name = "North", Keycode = "4711" };
new Parcel { Code = "P-1", Contents = "glass", Route = "via-Oslo", At = north };
new Slip { Line = "paid" };
}
principal Cleo => Account.Single(a => a.Email == "[email protected]");
[Test(World)]
[runas(Cleo)]
void a_held_row_is_read_as_the_actor_inside_the_block_and_as_the_caller_after_it() {
var seen = ReadHeld(Parcel.Single(p => p.Code == "P-1"));
Assert.Equal("glass||4711", seen.Before); // Cleo, a clerk: the contents and the keycode, not the route
Assert.Equal("|via-Oslo|", seen.Inside); // the machine, a dispatcher: the route, and neither of hers
Assert.Equal("|via-Oslo", seen.Queried); // …the same as a row the block reads for itself
Assert.Equal("glass||4711", seen.After); // Cleo again: hers are back, and the route is not left behind
}
[Test(World)]
[runas(Cleo)]
void what_the_actor_read_first_is_not_left_on_the_row_for_the_caller() {
var seen = ReadFirstInside(Parcel.Single(p => p.Code == "P-1"));
Assert.Equal("|via-Oslo|", seen.Inside);
Assert.Equal("glass||4711", seen.After);
}
[Test(World)]
[runas(Cleo)]
void a_row_the_actor_may_not_read_at_all_reads_as_nothing_inside_the_block() {
var seen = ReadSlip(Slip.Single());
Assert.Equal("paid", seen.Before);
Assert.Equal("|0", seen.Inside);
Assert.Equal("paid", seen.After);
}
[Test(World)]
[runas(Cleo)]
void on_behalf_of_the_caller_what_either_may_not_read_is_hidden() {
var seen = ReadHeldFor(Parcel.Single(p => p.Code == "P-1"), Cleo);
Assert.Equal("|", seen.Inside);
Assert.Equal("glass|", seen.After);
}
[Test(World)]
[runas(Cleo)]
void a_value_taken_before_the_block_is_the_callers() {
Assert.Equal("glass|", CarriedIn(Parcel.Single(p => p.Code == "P-1")));
}Two actors at once, each as itself — and the person who started them still herself:
using Osysharp.Observability;
[Role] enum DepotRole { Sorter, Weigher, Courier }
[Principal] entity Account {
[Unique, MaxLength(200)] string Email;
security { allow read when IsAuthenticated; }
}
[RoleGrant] entity RoleGrant {
[Required] Account Holder;
[Required] DepotRole Level;
security { allow read when IsAuthenticated; }
}
policy IsSorter => RoleGrant.Any(g => g.Holder == user && g.Level == DepotRole.Sorter);
policy IsWeigher => RoleGrant.Any(g => g.Holder == user && g.Level == DepotRole.Weigher);
policy IsCourier => RoleGrant.Any(g => g.Holder == user && g.Level == DepotRole.Courier);
[Actor(Roles = [DepotRole.Sorter])]
public class SortingMachine { public static Account Principal => new Account { Email = "[email protected]" }; }
[Actor(Roles = [DepotRole.Weigher])]
public class Scales { public static Account Principal => new Account { Email = "[email protected]" }; }
entity Parcel {
[Required, MaxLength(40)] string Code;
[MaxLength(40)] string Bay = "";
int Grams;
[MaxLength(80)] string Note = "";
[ModifiedBy] Guid? ChangedBy;
security {
allow read when IsAuthenticated;
allow update Bay when IsSorter; // only the sorting machine places a parcel
allow update Grams when IsWeigher; // only the scales weigh one
allow update Note when IsCourier; // a courier may leave a note
deny read Grams when !IsWeigher; // …and only the scales read a weight
}
}
/// Each is a task when it is started without `await`: it runs beside its caller, as its own actor.
async Task<string> Place(Guid parcel, string bay) {
await Task.Delay(5);
runas (SortingMachine) {
var p = Parcel.Single(x => x.Id == parcel);
await Task.Delay(10); // still open while the scales open and close theirs
p.Bay = bay;
return $"placed, weight seen: {p.Grams}"; // the sorter may not read a weight: hidden, so it reads 0
}
}
async Task<string> Weigh(Guid parcel, int grams) {
runas (Scales) {
var p = Parcel.Single(x => x.Id == parcel);
p.Grams = grams;
}
await Task.Delay(5);
runas (Scales) { return $"weighed: {Parcel.Single(x => x.Id == parcel).Grams}"; }
}
[TestFixture]
void Depot() {
var cleo = new Account { Email = "[email protected]" };
new RoleGrant { Holder = cleo, Level = DepotRole.Courier };
new Parcel { Code = "P-1" };
}
principal Cleo => Account.Single(a => a.Email == "[email protected]");
[Test(Depot)]
[runas(Cleo)]
void two_machines_work_at_once_each_as_itself_and_the_courier_stays_the_courier() {
var parcel = Parcel.Single(p => p.Code == "P-1");
var placing = Place(parcel.Id, "B4"); // started…
var weighing = Weigh(parcel.Id, 1200); // …and started: both are running now
parcel.Note = "fragile"; // Cleo's own change, while they work
var said = await Task.WhenAll(placing, weighing);
UnitOfWork.Commit();
Assert.Equal("placed, weight seen: 0", said[0]); // the sorter never read as the scales (they wrote 1200)
Assert.Equal("weighed: 1200", said[1]); // the scales read what they wrote
var after = Parcel.Single(p => p.Code == "P-1");
Assert.Equal("B4", after.Bay);
Assert.Equal("fragile", after.Note);
Assert.Equal(0, after.Grams); // Cleo is a courier: the weight is not hers to read…
Assert.True(Security.IsMasked(after.Grams)); // …and that 0 is a withheld value, not a weighed one
Assert.Equal(Cleo.Id, after.ChangedBy); // the last change stored is her note, under her name
}See also#
- runas (actor, onBehalfOf: person) — an actor acting for a person — an actor acting for a person:
onBehalfOf:andscope: - async / await and Task — tasks: starting work beside the caller, and what a task may write
- What may go past the caller's own rights — and what never does — every route past one person's own rights, side by side
- acting for another principal — a device or integration that signs in as itself
- runas —
runasin a test, where any seeded principal may be named - role grants (and the first admin) — the roles an actor's
Rolesnames - What a workflow body may write — what a workflow body may write without one