Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

[Actor] — a principal that code may run as

[Actor(Roles = [<Role>.<Member>, …])] public class <Name> { public static <Principal> Principal => new <Principal> { … }; } · runas (<Name>) { … } · Actor.Of<<Name>>() · Actor.Of(<type>) · Actor.From(<row>)

A class marked `[Actor]` declares a principal that code may run as: a robot, a nightly job, an integration, a digital coworker. The platform makes its account once, from the class's own `Principal` member, and gives it exactly the roles the declaration names. `runas (Name) { … }` then runs a block as it — reads, writes and calls all answer to its roles, not the caller's — and commits what the block wrote as the actor. It is the one thing code outside a test may run as; no other account can be acted as.

preview3 examples compiled by CIsecurityactorrolesserver

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 change

Signature#

[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 name

Description#

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 blockhidden — 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 blockits 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 allevery member hidden
the same members after the blockwhat 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 seesit 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 / Finda 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 conditionwhether 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:

whenwhat happens
the block startswhat you have pending is committed first, as you
the block endswhat it wrote is committed, as the actor — its [CreatedBy] / [ModifiedBy] stamps and its change-trail entries name the actor
the block throwswhat 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 ita block in a task
what you have pending when it startscommitted first, as youleft pending — a task may not commit its caller's work — and not there for the block
what the block readswhat is stored, as the actorthe same
what the block wrote, when it endscommitted, as the actorthe 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 writeanswer
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 Typecompile 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 rowcompile error: a row is never run as because code can read it
runas (Stapler) — a class that is not an actorcompile 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#

Related

runas (actor, onBehalfOf: person) — an actor acting for a person

`runas (Actor, onBehalfOf: person) { … }` runs a block as a declared actor FOR a person: inside it nothing is read or…

runas

Runs a block as a given principal, so security rules apply exactly as they would for that user. It is how you test that…

What may go past the caller's own rights — and what never does

A request always runs as the person who made it; there is no system mode, sudo or definer's rights a function can…

acting for another principal

A shared terminal, a kiosk, a scanner or a back-office integration is a **user of your app**, not a mode of it. It…

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…

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…

auth bootstrap (login, before anyone is signed in)

Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap`…

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…

audit read access (app.Audit)

`app.Audit` configures the app's audit trails — WHO may read each one, and whether it is recorded at all. An app that…