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:
- the device is a user. It holds its own
[Principal]row and its own[Role], exactly like a person. - it authenticates as itself — its own API key or its own password. It never presents anybody else's.
- the person it acts FOR is an ordinary parameter of the call.
userstays the device. - you decide, in the function and in
security {}, which subjects that role may name. - 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#
// 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
}
}# 4 — mint the account AND its role in one act; then its key, printed once
osy user add [email protected] --role Scanner --password '…'
osy user apikey generate [email protected]Description#
Why the obvious design does not work#
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#
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:
[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 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.
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#
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:
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 };
}- The actor is never a parameter.
FiledBy = Session.CurrentUseris the only honest stamp, and it is why a record filed by a device is still attributable to that device afterwards. AfiledByargument would be a lie the caller writes. - 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". - The refusal reaches the caller as a status, not a crash. A
ValidationExceptionis a400and aNotFoundExceptiona404, with your wording — see [[api-rest#who-is-user]].
Publishing it, and which credential the device presents#
The device is authenticated exactly like a person, so the API needs no special mode:
[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#
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.
# the principal row AND its RoleGrant row, in one act. --role is REQUIRED (repeatable).
osy user add [email protected] --role Scanner --password 'a-long-one'
# …and the credential it will present, printed ONCE — store it on the device
osy user apikey generate [email protected]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.
⛔ 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.
// `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:
--roleis required, not optional. An account with no role holds no authority yoursecurity {}can act on, and an omitted--roleis far more often a forgotten flag than an intent.--via-signupis the alternative: it runs the app's ownSignupand lets that decide what it grants.- A
User.Count() == 0first-admin gate inSignupis 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--roleand leavingSignupto 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).
The lint that catches the race#
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.
[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 existWhy 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:
# 1 + 2 — `osy user add` REFUSES without `app.Auth` (it names the FIELDS; `app.AuthBootstrap` names the FUNCTIONS)
osy user add [email protected] --role Scanner --password 'a-long-one'
# 3 — the credential the device presents, printed ONCE
osy user apikey generate [email protected]// 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#
| 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#
- publishing a REST API (app.Apis) —
app.Apis,ApiAuth, and the table of whouseris on an authenticated API call - Adding an account — every flag of
osy user add, and itsosyrin app user addtwin - role grants (and the first admin) — the grant table and the
whenpredicates the roles above are written with - auth bootstrap (login, before anyone is signed in) — the ephemeral auth principal, and why it is not an "act as" mechanism
- runas — proving the rules above deny the callers they should
- Running a function —
osy run --as, and why it asks for the principal's own password