Summary#
use Osysharp.Identity@0; gives an app its [Principal] — Principal — and its grant table — RoleGrant — so that
every kit that knows about people (accounts, invitations, organisations, a user manager) references the same two
types. The app keeps its own roles:
[Role] enum AppRole { Authenticator, Agent, Lead }
policy IsLead => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Lead);Signature#
[Principal] public entity Principal {
string Email; // [Required, Unique], stored trimmed and lower-case by the flows that write it
string DisplayName = "";
bool IsActive = true; // an inactive account cannot sign in; it is kept, not deleted
string? ExternalId; // the identity provider's id, for an account it provides
IdentitySource Source; // Local | Idp
DateTime? EmailVerifiedAt; // when a message sent to Email was received and acted on
DateTime? LastLoginAt;
string? ApiKeyHash; // an API key's hash — how a program (a service, an agent) signs in; minted by the platform
string? ApiKeyHash2; // a second key, so one can be replaced with no moment when neither works
}
[RoleGrant] public entity RoleGrant { Principal Member; role Role; } // `role` = the app's [Role] enum
policy IsAuthenticator => RoleGrant.Any(g => g.Member == user && g.Role == role.Authenticator);
string NormalisedEmail(string email); bool LooksLikeEmail(string email); Principal? CurrentPrincipal();
string PersonName(Principal p); // DisplayName, else Email, else "" (a system account) — one expression, so a render may call it
bool IsUnreachableAddress(string email); // under the reserved `.invalid` domain: a system account, mailed nothingDescription#
One principal, many kinds of account#
Principal is the app's one [Principal]; every kind of account derives from it and shares its table, so [Unique] Email means one address, one account across all of them. In every security rule user is the base Principal,
and user is Member (or Session.CurrentUser is Member) tells the kinds apart. A type derived from Principal signs
in through a sign-in written against the base, and each kind states its own security { } grants.
What every kind stands on#
The package's rules are the floor of every type derived from Principal (entity Sub : Base): an account
edits its own DisplayName, but Email, EmailVerifiedAt, Source, ExternalId, IsActive and LastLoginAt are
written only by the sign-in flow (IsAuthenticator) — whatever the derived type's own block grants. So nobody marks
their own address proven, reactivates themselves, or changes the address they sign in with except through a verified
change. The two API-key hashes are credentials: read by the sign-in flow alone, written by nobody signed in (the
platform mints and revokes keys — osy user apikey generate — outside the app's rules).
Everybody signed in sees everybody — a team's directory. An app whose people must not see each other narrows it on
the type its accounts ARE — partial entity User { security { deny read when !IsLead where Id != user.Id; } } for an
Accounts app. A deny stated on Principal itself holds too — on every type derived from it (entity Sub : Base).
Roles are the app's#
RoleGrant.Role is typed role — the app's own [Role] enum, bound when the package is compiled into the app
(role — a package's name for the app's own roles) — so a grant is an ordinary row of the app's vocabulary and the platform resolves it like any
grant (role grants (and the first admin)). The app's enum must have an Authenticator member: that is the role the sign-in flow
runs as, and IsAuthenticator names it.
- Each person reads their own grants and nobody else's. What a role unlocks is answered from the caller's own role names, never by reading the table. The sign-in flow reads them too — deciding what a sign-in asks for ("every admin uses two-step sign-in") needs to know who holds which role — and it already grants them.
- Who may grant a role is the app's decision: the table is
[Authority(Extensible)], so an app states it beside its roles —partial entity RoleGrant { security { allow read, create, delete when IsAdmin; } }. - Unscoped. A role here holds everywhere in the app; a role within one organisation is a different table's row ([[security-organizations-kit#roles]]).
- One row per person and role.
RoleGrantis[Unique(Member, Role)], so two promotions of one person made at the same moment cannot leave two identical grants: the later commit is refused with "They already hold that role." A grant path that may race takes that as done — The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it'sGrantRolerecords its change again and it applies as nothing left to do. Granting a role somebody holds is not an error to guard against at every call site; check first (if (!RoleGrant.Any(…))) and let the index settle the race the check cannot see.
Which roles people are offered#
app.Roles says which of the app's roles its pages hand out, in order, and how each reads to a person. Kits that offer
a role — an invitation form, a people manager — offer exactly these, and refuse any other: a role left out (an
integration's, the sign-in flow's Authenticator) is never offered.
app.Roles = new RolesSetup { Offered = [
new RoleChoice { Role = AppRole.Agent, Label = "Agent", Description = "Works tickets." },
new RoleChoice { Role = AppRole.Admin, Label = "Admin", Description = "Runs the desk." },
] };OfferedRoles() answers them for a page, IsOfferedRole(r) asks of one, and RoleLabel(r) gives its name. Each
RoleChoice also says where the role may be held — HeldIn = RoleScope.App, RoleScope.Organization, or either (the
default) — and the kits offer and give it only there (RoleChoice.HeldIn — where a role may be held: across the app, inside an organisation, or either). Changing who
holds a role through a page, with a record of who did it, is The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it.
A role a page should NAME but never hand out — an operator's role, an integration's — is listed with Grantable = false:
RoleLabel(r) names it wherever somebody holds it, while OfferedRoles() leaves it out, IsOfferedRole(r) answers
false, and the kits refuse to grant or take it.
new RoleChoice { Role = AppRole.Operator, Label = "Operator", Description = "Runs the platform.", Grantable = false }When somebody is given a role, or loses one#
RolesSetup.Events is what the app does about it — put a new agent on the desk's queues, take a leaver off them. The
kits that change roles tell it: the user manager (The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it) for a grant or a revoke, an accepted
invitation (The Invitations kit — invite an address with a role, answered only by that address) for the role it was sent with, and a sign-up (The UserAccounts kit — sign-in done properly, once) for
each role the app gives a new account (AccountsSetup.SignupRole, FirstAccountRole). It runs inside that change's own
write, as the system — so what it writes lands with the grant or not at all, and needs no grant of the sign-in flow's —
and it is told once per change that took effect.
public class DeskRoles : RoleEvents {
public override void Granted(Principal person, role r) { JoinTheDesk(person); } // which queues is the app's call
public override void Revoked(Principal person, role r) { LeaveWhatNoLongerFits(person); }
}
app.Roles = new RolesSetup { Offered = [ … ], Events = new DeskRoles() };Signing in#
The kit has no password: The UserAccounts kit — sign-in done properly, once is the default way in, and adds User : Principal with the
credential. An app with its own way in (a PIN, a company's identity provider) derives from Principal and writes its
[AuthMethod]s — see the example.
Examples#
A club that signs members in with a number and a PIN#
app Club {
use Osysharp.Identity;
model "model/**/*.osy";
}
using Osysharp.Identity;
[Role] enum ClubRole { Authenticator, Member, Steward }
policy IsSteward => RoleGrant.Any(g => g.Member == user && g.Role == ClubRole.Steward);
entity Member : Principal {
[Required, Unique, MaxLength(20)] string Number;
[MaxLength(200)] string PinHash;
security {
allow read when IsAuthenticated || IsAuthenticator;
allow create when IsAuthenticator;
allow update when IsAuthenticated || IsAuthenticator where Id == user.Id;
allow update when IsAuthenticator;
deny read PinHash when !IsAuthenticator;
}
}
entity StewardNote {
[Required, MaxLength(200)] string Body;
security { allow read, create when IsSteward; }
}
[AuthMethod]
string SignInWithPin(string number, string pin) {
var m = Member.Where(x => x.Number == number.Trim()).FirstOrDefault();
if (m == null || !m.IsActive) { Security.VerifyPassword(pin); return ""; } // a miss spends a hash too
if (!Security.VerifyPassword(pin, m.PinHash)) { return ""; }
m.LastLoginAt = DateTime.UtcNow;
return Security.IssueJwt(m.Id, m.Email);
}
[AuthMethod]
string Join(string email, string number, string pin) {
if (!LooksLikeEmail(email)) { throw new ValidationException("That does not look like an email address."); }
var m = new Member { Email = NormalisedEmail(email), Number = number.Trim(), PinHash = Security.HashPassword(pin) };
new RoleGrant { Member = m, Role = ClubRole.Member };
return Security.IssueJwt(m.Id, m.Email);
}
bool AmMember() { return Session.CurrentUser is Member; }
app.AuthBootstrap = new AuthBootstrap { Role = ClubRole.Authenticator, Login = SignInWithPin, Signup = Join };See also#
- The UserAccounts kit — sign-in done properly, once — the default way in:
User : Principal, sign-up, reset, verification, pages role— a package's name for the app's own roles — how the kit names the app's roles- The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it — the user manager, which grants and revokes the offered roles
- RoleChoice.HeldIn — where a role may be held: across the app, inside an organisation, or either — where an offered role may be held: across the app, inside an organisation, or either
- entity Sub : Base — deriving from
Principal, and the floor it stands on - auth bootstrap (login, before anyone is signed in) — wiring an
[AuthMethod]sign-in