# 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 holds its own `[Principal]` row and its own `[Role]`, it authenticates as ITSELF (an API key or a password), and the person it is acting for is an ORDINARY PARAMETER of the call — checked by your own `security {}` and by your own function, never taken on trust. Nothing outside a `[Test]` mints a credential for somebody who did not present one, so there is no "become this user" verb to look for. The role and the key are minted from the CLI — `osy user add <login> --role <R>` writes the principal row AND its role grant, `osy user apikey generate <login>` prints the key once — which is how an app with no admin UI gets its first privileged account.

<!-- id: security-acting-for-another-principal · area: security · stability: stable · html: https://osysharp.com/reference/security/acting-for-another-principal/ -->

## Summary        {#summary}
A shared device — a hallway terminal, a warehouse scanner, a front-desk tablet — and a back-office integration are
the same problem wearing two costumes: **something that is not a person files work that belongs to a person.**

Osy# answers it with pieces you already have, and with no new concept:

1. the device **is a user**. It holds its own `[Principal]` row and its own `[Role]`, exactly like a person.
2. it **authenticates as itself** — its own API key or its own password. It never presents anybody else's.
3. the person it acts FOR is an **ordinary parameter** of the call. `user` stays the device.
4. **you** decide, in the function and in `security {}`, which subjects that role may name.
5. the role and the key are minted **from the CLI**, so an app with no admin UI still has a way to get its first
   privileged account.

⛔ **There is no "act as this user" verb, and looking for one is the wrong turn.** `runas`, `Ui.SignInAs` and
`Api.KeyFor` all exist and are all **refused at compile time outside a `[Test]`** — minting a working credential for
someone who did not present one is impersonation anywhere else. `osy run --as` and `osy import --as` are real, and
they are not an exception: they take **that principal's own password**. The absence is deliberate, and it is why the
design above is the design rather than a workaround for a missing feature.

## Signature      {#signature}
```osy syntax
// 1 — the device is a principal with a role of its own
[Role] enum StaffRole { Authenticator, Courier, Scanner }

// 2 — the role is a policy over your own grant table, like any other
policy IsScanner => RoleGrant.Any(g => g.Holder == user && g.Level == StaffRole.Scanner);

// 3 — the SUBJECT is a field; the rule admits the subject themselves OR the device
entity Handover {
  [Required("…")] Account Courier;    // whose work this is  — a PARAMETER at the call site
  [Required("…")] Account FiledBy;    // who pressed the button — never a parameter
  security {
    allow create where Courier == user;   // the courier files their own
    allow create when IsScanner;          // …and the shared scanner files anyone's
  }
}
```
```bash
# 4 — mint the account AND its role in one act; then its key, printed once
osy user add scanner@depot.example --role Scanner --password '…'
osy user apikey generate scanner@depot.example
```

## Description    {#description}

### Why the obvious design does not work   {#why-not-one-key}
The first design everybody writes is *"give the device an API key and let it post whoever's name is on the screen"*.
It does not work, and the reason is a single sentence from [[api-rest#who-is-user]]:

> **An API key is a PER-USER credential. There is no app-wide API key.** A key is minted against exactly one
> `[Principal]` row, and a request carrying it runs **as that user**.

So a request from the terminal has `user` = the terminal. Every rule you wrote in the shape
`allow read where Owner == user` therefore matches the terminal's own rows — that is, none — and the natural next
move is the wrong one: opening the entity to `IsAnonymous`, or dropping `Auth` from the API so the route "works".
That opens the same entity on every other surface too, to the whole internet.

⚑ **The fix is not a second kind of credential. It is admitting the device is a second kind of USER.** Once the
device has its own row and its own role, `user` being the device is exactly right — and everything below is ordinary
Osy#.

### The device holds its own account, its own role   {#the-integration-is-a-principal}
Nothing about the device's account is special. It is a row of the same `[Principal]` entity a person gets, with a
grant of a role you invented for it:

```osy title="the app: three roles, one of them a machine's" test app=security-acting-for-another-principal
[Role] enum StaffRole { Authenticator, Courier, Scanner }

[Principal] entity Account {
  [Unique, MaxLength(200)] string Email;
  [MaxLength(200)] string PasswordHash;
  [MaxLength(255)] string? ApiKeyHash;    // the key in force — see [[api-rest#api-key-storage]]
  [MaxLength(255)] string? ApiKeyHash2;   // the rotation slot
  security {
    allow read when IsAuthenticated;
    allow read, create when IsAuthenticator;
    // ⛔ ALL THREE. A credential with no field-level `deny read` rides `Session.CurrentUser` to the browser.
    deny read PasswordHash when !IsAuthenticator;
    deny read ApiKeyHash   when !IsAuthenticator;
    deny read ApiKeyHash2  when !IsAuthenticator;
  }
}

entity RoleGrant {
  [Required("Name the account this grant belongs to.")] Account Holder;
  StaffRole Level;
  security {
    allow read when IsAuthenticated;
    allow create when IsAuthenticator;
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.Holder == user && g.Level == StaffRole.Authenticator);
policy IsScanner       => RoleGrant.Any(g => g.Holder == user && g.Level == StaffRole.Scanner);
```

`Scanner` is a role like any other. It is not privileged by being a machine's — it is privileged by exactly the
grants you write for it, and by nothing else.

### Two grants, and the second is the whole feature   {#the-rule-shape}
The entity that records the work carries the subject as a **reference**, and its `security {}` admits two callers:
the subject acting for themselves, and the device acting for them.

```osy title="the record: whose work it is, and who filed it" test app=security-acting-for-another-principal
entity Handover {
  [Required("Say which courier took the parcel.")] Account Courier;
  [Required("Every handover records who filed it.")] Account FiledBy;
  [Required("A parcel reference is what makes the handover findable.")] [MaxLength(40)] string Parcel;
  security {
    allow read   where Courier == user;   // a courier reads their own handovers
    allow create where Courier == user;   // …and files their own
    allow create when IsScanner;          // …and the shared scanner files anyone's
  }
}
```

The two `allow create` lines are read as an OR, and the difference between them is the whole mechanism:

| the line | what it is | who it admits |
|---|---|---|
| `allow create where Courier == user` | a **row filter** — it correlates a column to the caller | the subject, for their own rows only |
| `allow create when IsScanner` | a **guard** — a predicate about the caller, with no column in it | the device, for anybody's rows |

⚠ **A `where` rule can never express "on behalf of", and reaching for one is the commonest wrong turn.** A `where`
correlates the ROW to the CALLER, and the whole point here is that the row belongs to somebody who is not the caller.
The device's grant is therefore a `when`, and everything that narrows it lives in the function — the next section.

### What stops the device naming anyone it likes   {#stopping-a-forged-subject}
`allow create when IsScanner` is a broad grant on purpose: it says the scanner may file for **somebody else**, and no
declarative rule can say which somebody, because the answer is your app's own business logic. Three things narrow it,
and all three are ordinary code:

```osy title="the endpoint: the subject is an argument, the actor is not" test app=security-acting-for-another-principal
void RecordHandover(string courierEmail, string parcel) {
  var courier = Account.Where(a => a.Email == courierEmail).FirstOrDefault();
  if (courier == null) { throw new NotFoundException("No account with that address."); }

  // ⛔ A SUBJECT THE CALLER CAN NAME IS NOT A SUBJECT THE CALLER MAY USE. Without this line the scanner in the
  //    loading bay can file a handover against the finance director, who is also an Account. The predicate is
  //    yours to choose; having one is not optional.
  if (!RoleGrant.Any(g => g.Holder == courier && g.Level == StaffRole.Courier)) {
    throw new ValidationException("That account is not a courier.");
  }

  // ⚑ `FiledBy` IS THE ONE FIELD THE CALLER CANNOT CHOOSE. `Session.CurrentUser` is whoever presented the
  //    credential — the scanner here, the courier when a courier files their own — so the audit answer to
  //    "who did this?" cannot be forged by the request that asks the question.
  new Handover { Courier = courier, FiledBy = Session.CurrentUser, Parcel = parcel };
}
```

1. **The actor is never a parameter.** `FiledBy = Session.CurrentUser` is the only honest stamp, and it is why a
   record filed by a device is still attributable to that device afterwards. A `filedBy` argument would be a lie the
   caller writes.
2. **A named subject is not a permitted subject.** The `RoleGrant.Any(…)` check is what stops the terminal in the
   hallway filing against an account that merely exists. Choose the predicate your domain actually means — "holds
   the Courier role", "is on this depot's roster", "has an open assignment".
3. **The refusal reaches the caller as a status, not a crash.** A `ValidationException` is a `400` and a
   `NotFoundException` a `404`, with your wording — see [[api-rest#who-is-user]].

### Publishing it, and which credential the device presents   {#the-route}
The device is authenticated exactly like a person, so the API needs no special mode:

```osy title="the published route — an ordinary API-key API" test app=security-acting-for-another-principal
[AuthMethod]
string Login(string email, string password) {
  var a = Account.Where(x => x.Email == email).FirstOrDefault();
  if (a == null) { Security.VerifyPassword(password); return ""; }
  if (Security.VerifyPassword(password, a.PasswordHash)) { return Security.IssueJwt(a.Id, a.Email); }
  return "";
}

app.AuthBootstrap = new AuthBootstrap { Role = StaffRole.Authenticator, Login = Login };

app.Apis = [
  new RestApi("Depot") {
    Route = "depot",
    Auth  = new ApiAuth { ApiKey = true },
    Endpoints = [ new Endpoint(RecordHandover) { Method = HttpMethod.Post, Path = "/handover" } ],
  },
];
```

A key belongs to a person or to a device with equal ease, so a device on a wall and a nightly job on a server are the
same story:

| the caller | the credential it presents | what `user` is |
|---|---|---|
| a shared terminal | `X-API-Key: pk_…`, minted for the terminal's own account | the terminal |
| a back-office job | the same, minted for the job's own account | the job |
| a person, signed in | their bearer token from `Login` | that person |

### How the first role is minted when the app has no admin UI   {#minting-the-role}
This is the half that has no answer inside the app, and it is where the design usually stalls: the `Scanner` grant
has to exist before the scanner can do anything, and a brand-new deployment has no screen for creating it.

**It is not created in the app. It is created from the CLI, and the verb writes both rows.**

```bash
# the principal row AND its RoleGrant row, in one act. --role is REQUIRED (repeatable).
osy user add scanner@depot.example --role Scanner --password 'a-long-one'

# …and the credential it will present, printed ONCE — store it on the device
osy user apikey generate scanner@depot.example
```

Against a deployed platform the same two acts are `osyrin app user add …` and
`osyrin app user apikey generate …` — the same store, the same flags. The full flag set is
[Adding an account](https://osysharp.com/reference/local/adding-an-account/).

⛔ **`osy user add` refuses unless the app declares `app.Auth`**, and this is the single most common blocker. It is a
DIFFERENT declaration from `app.AuthBootstrap`: the bootstrap names the *functions* your login page calls, which is
all that page needs; `app.Auth` names the **fields** that hold the login and the hash, which is what anything OUTSIDE
the app needs, because it has no page to post to. Declare both.

```osy title="the two lines that let an operator create an account" test app=security-acting-for-another-principal
// `app.AuthBootstrap` (above) is how the app's OWN login page signs somebody in.
// `app.Auth` is how anything outside the app does — the CLI, the console, `--as`.
app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash };

// A credential not named here is written to the entity-change audit trail VERBATIM.
app.Audit = new AuditConfig {
  Redact = new AuditRedaction {
    Properties = [Account.PasswordHash, Account.ApiKeyHash, Account.ApiKeyHash2],
  },
};
```

Three further facts about that bootstrap, each of which has cost somebody a design:

- **`--role` is required, not optional.** An account with no role holds no authority your `security {}` can act on,
  and an omitted `--role` is far more often a forgotten flag than an intent. `--via-signup` is the alternative: it
  runs the app's own `Signup` and lets that decide what it grants.
- **A `User.Count() == 0` first-admin gate in `Signup` is closed by the FIRST row added by ANY means** — a browser
  signup, `osy user add`, `osy import`. It is a real pattern and a fragile one; prefer minting the privileged
  account with `--role` and leaving `Signup` to grant only the ordinary role.
- **`app.AuthBootstrap`'s ephemeral principal is NOT "acting for" anyone.** It is a role with *no user at all*,
  armed by the engine only for the functions the bootstrap declares, so that a login can read a row before anybody
  is authenticated. It cannot be borrowed for this. See [auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/).

### The lint that catches the race   {#the-lint}
`osy lint` reports `security-integration-role-granted-by-signup-order` (SHOULD) when the two facts above meet in one
app: a role decided by **how many rows already exist**, in an app that mints **per-user API keys**.

```osy title="the shape the lint fires on — a machine's role decided by whoever signs up first" syntax
[AuthMethod]
string Signup(string email, string password) {
  bool isFirst = User.Count() == 0;                                  // ⛔ the ordinal test
  var u = new User { Email = email, PasswordHash = Security.HashPassword(password) };
  new RoleGrant { Grantee = u, Level = isFirst ? StaffRole.Scanner   // ⛔ the machine's role, by race
                                               : StaffRole.Courier };
  return Security.IssueJwt(u.Id, u.Email);
}

app.Apis = [ new RestApi("Depot") { Auth = new ApiAuth { ApiKey = true }, … } ];   // ⛔ …and machine accounts exist
```

**Why the conjunction, and not the count alone.** "The first account to sign up becomes the ADMIN" is a real pattern
people deliberately choose, and the bullet above calls it fragile rather than wrong. What makes the shape above
different is that the intended holder **is not somebody who signs up at all** — it is a terminal on a wall. An API
key is minted against one `[Principal]` row from the CLI ([[api-rest#who-is-user]]), so the count can never reach the
device; it can only reach whoever loads your public signup page first, in a window that is open from deploy until the
first registration. The author already holds the tool that provisions it, which is why the finding costs nothing to
act on.

**What to write instead** — the four acts, in order:

```bash
# 1 + 2 — `osy user add` REFUSES without `app.Auth` (it names the FIELDS; `app.AuthBootstrap` names the FUNCTIONS)
osy user add scanner@depot.example --role Scanner --password 'a-long-one'
# 3 — the credential the device presents, printed ONCE
osy user apikey generate scanner@depot.example
```
```osy title="…and Signup stops deciding, so there is no race left to lose" syntax
// 4 — and `Signup` stops deciding: everybody who signs up is a courier
new RoleGrant { Grantee = u, Level = StaffRole.Courier };
```

The rule is SHOULD rather than MUST because the decisive fact — that this role is a *machine's* — is inferred from
the app minting API keys, not proven of that role. An author who really does mean the first signup to hold it says
so where it fires: `[SuppressWarning("security-integration-role-granted-by-signup-order")]` on the function.

### Four things that look like this and are not   {#not-this}
| | what it is | may I use it here? |
|---|---|---|
| `runas (P) { … }` | rebinds the acting principal inside a `[Test]` | **no** — a compile error outside a test |
| `Api.KeyFor(P)` / `Ui.SignInAs(P)` | mint a working credential for a principal in a `[Test]` | **no** — same refusal, same reason |
| `osy run --as` / `osy import --as` | run a function or an import AS a real user | only as tooling, and it takes **that user's own password** — naming a principal never makes you one |
| the bootstrap's ephemeral principal | a role with no user, for the declared auth methods only | **no** — it is not anybody, so it cannot be somebody |

⚑ The pattern in all four: **presenting a credential is the only way to be somebody.** The three test-only verbs are
allowed to break that precisely because a test's whole world is disposable; production has no equivalent, by design.

## See also       {#see-also}
- [publishing a REST API (app.Apis)](https://osysharp.com/reference/api/rest/) — `app.Apis`, `ApiAuth`, and the table of who `user` is on an authenticated API call
- [Adding an account](https://osysharp.com/reference/local/adding-an-account/) — every flag of `osy user add`, and its `osyrin app user add` twin
- [role grants (and the first admin)](https://osysharp.com/reference/security/role-grants/) — the grant table and the `when` predicates the roles above are written with
- [auth bootstrap (login, before anyone is signed in)](https://osysharp.com/reference/security/auth-bootstrap/) — the ephemeral auth principal, and why it is not an "act as" mechanism
- [runas](https://osysharp.com/reference/testing/runas/) — proving the rules above deny the callers they should
- [Running a function](https://osysharp.com/reference/local/running-a-function/) — `osy run --as`, and why it asks for the principal's own password
