Summary#
An app hands its roles out in two places. A RoleGrant holds a role across the whole app — an agent works every
ticket. A membership's role holds only inside one organisation ([[security-organizations-kit#roles]]) — a company
admin looks after that company's people and nobody else's. Some roles only make sense in one of the two, so each offered
role says which:
HeldIn | the role may be held | offered and given by the kits |
|---|---|---|
RoleScope.Anywhere (default) | across the app, or inside an organisation | in both places |
RoleScope.App | across the app only — a RoleGrant | on an app-wide invitation, and as a person's app role |
RoleScope.Organization | only inside an organisation | on an invitation into one, and as a role in one |
app Desk {
use Osysharp.Ui;
use Osysharp.Workflow;
use Osysharp.Identity@0;
use Osysharp.Mail@0;
use Osysharp.EmailTemplates@0;
use Osysharp.UserAccounts@1;
use Osysharp.Organizations@0;
use Osysharp.Invitations@2;
model "model/**/*.osy";
}
using Osysharp.Identity;
using Osysharp.UserAccounts;
using Osysharp.Organizations;
using Osysharp.Invitations;
[Role] enum DeskRole { Authenticator, Agent, Admin, CompanyAdmin }
policy IsAdmin => RoleGrant.Any(g => g.Member == user && g.Role == DeskRole.Admin);
policy MayInvite => IsAdmin;
policy ManagesOrganizations => IsAdmin;
app.Accounts = new AccountsSetup { AppName = "Desk", SignupOpen = false, FirstAccountRole = DeskRole.Admin };
app.Roles = new RolesSetup { Offered = [
new RoleChoice { Role = DeskRole.Agent, Label = "Agent", Description = "Works tickets.", HeldIn = RoleScope.App },
new RoleChoice { Role = DeskRole.Admin, Label = "Admin", Description = "Runs the desk.", HeldIn = RoleScope.App },
new RoleChoice { Role = DeskRole.CompanyAdmin, Label = "Company admin",
Description = "Looks after their company's people.", HeldIn = RoleScope.Organization },
] };
entity Company : Organization {
security {
allow read, create, update when IsAdmin;
allow read when IsAuthenticator;
allow read when IsAuthenticated where IsMemberOf(this);
}
}
// An app-wide invitation: the form offers Agent and Admin, never Company admin.
[Route("/people")] [Title("People")]
component PeoplePage() {
render { Stack(gap: 5, p: 5, canSee: MayInvite) { Card("Invite someone") { InviteForm(canSend: MayInvite); } } }
}
// An invitation into one company: the form offers Company admin (and nothing held only across the app).
[Route("/companies/{id}")] [Title("Company")]
component CompanyPage(Guid id) {
live var company = Company.FirstOrDefault(c => c.Id == id);
render {
if (company != null) {
Stack(gap: 5, p: 5, canSee: MayInvite) { Card("Invite to " + company.Name) { InviteForm(into: company, canSend: MayInvite); } }
}
}
}
Invitation InviteCompanyAdmin(string email, Company company) {
return Invite(email, DeskRole.CompanyAdmin, organization: company); // accepted: held inside an organisation
}Signature#
public class RoleChoice { role? Role; string Label; string Description; RoleScope HeldIn = RoleScope.Anywhere; bool Grantable = true; }
public enum RoleScope { Anywhere, App, Organization }
List<RoleOption> OfferedRoles(RoleScope where = RoleScope.Anywhere);
bool IsOfferedRole(role r, RoleScope where = RoleScope.Anywhere);Description#
Where is each role offered?#
OfferedRoles(where) answers the roles that may be held there, in the app's order: RoleScope.App gives every
offered role whose HeldIn is App or Anywhere, RoleScope.Organization every one whose HeldIn is Organization
or Anywhere. With no argument — RoleScope.Anywhere — it lists every offered role. IsOfferedRole(r, where) asks the
same of one role. A role listed Grantable = false is in neither: it is only named — RoleLabel(r) — where somebody holds it.
A role not listed in app.Roles at all is offered nowhere, whatever it is — the sign-in flow's Authenticator, an
integration's role ([[security-identity-kit#offered]]).
What do the kits do with it?#
- The invitation form (The Invitations kit — invite an address with a role, answered only by that address) offers the roles held where the invitation leads:
InviteForm()the app-wide ones,InviteForm(into: company)the ones held inside an organisation. Inviterefuses a role sent to the wrong place.Invite(email, DeskRole.CompanyAdmin)with no organisation is refused with "That role is held only inside an organisation — invite them to the organisation."; an app-wide role sent withorganization:is refused with "That role is held across the app, not inside an organisation."- The user manager (The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it) lists only app-wide roles as a person's app roles, and a new
integration is given one of those.
GrantRole(person, DeskRole.CompanyAdmin, …)is refused with "That role is held only inside an organisation — give it from the organisation's people.", andGrantInOrganization(company, person, DeskRole.Agent, …)with "That role is held across the app, not inside an organisation."
Both refusals come from the function, not the page, so an API call or a test is held to the same rule a form is.
When should a role be Anywhere?#
When it genuinely means the same thing in both places — a "Viewer" that may read the whole app, or one company's
records. Choosing App or Organization is what stops a person being given, in the wrong place, a role that carries
more than it should there: a company admin promoted across the app would look after every company's people.
HeldIn says where the kits may hand a role OUT; what a held role lets its holder do is still the app's own rules —
RoleGrant.Any(…) for an app role, HoldsRoleIn(company, …) for one held in an organisation.
What does sign-up give a stranger?#
SignupRole (The UserAccounts kit — sign-in done properly, once) is a RoleGrant — a role held across the whole app — and it is given to
everyone who fills in the sign-up form. So it must be a role that is safe in a stranger's hands: a customer, a member,
a reader. An organisation's owner is not one. Handed out at sign-up, an app-wide Owner that answers
ManagesPeople, ManagesOrganizations or MayInvite, or that writes the role grants or the organisations themselves,
lets anyone who signs up manage every organisation in the app.
The shape that keeps an owner to their own organisation is the one above: offer the role with
HeldIn = RoleScope.Organization, grant it per organisation, and ask for it in a where —
allow update where HoldsRoleIn(this, AppRole.Owner); on the organisation, and
where HoldsRoleIn(Company, AppRole.Owner) (or IsMemberOf(Company)) on every row that belongs to one. The app's own
staff can hold an app-wide role — as long as nobody gets it by signing up (FirstAccountRole, an invitation, or
osy user add … --role Admin).
Two MUST rules in osy lint (and printed by osy validate) say when this is wrong, and osy explain opens with the
same sentence:
security-signup-role-administers-everything— sign-up is open and the role it gives satisfies a policy that manages people, organisations, invitations or role grants across the app: "Anyone who signs up getsOwner, which manages every organisation in the app."security-tenant-rows-open-to-every-account— a row that belongs to an organisation is granted, with nowhere, to a caller every account can be: signed in, holding the sign-up role, or a member of some organisation.
See also#
- The Identity kit — who signs in, and which of the app's roles they hold —
app.Roles,RoleChoice, and the roles the app offers - The Invitations kit — invite an address with a role, answered only by that address — the invite form and
Invite, which offer and send a role only where it may be held - The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it — the user manager, which gives a role only where it may be held
- The Organizations kit — companies, teams, and roles held within one — memberships, and the role a membership holds inside one organisation
- role grants (and the first admin) — how an app-wide role is held
- The UserAccounts kit — sign-in done properly, once —
SignupRoleandFirstAccountRole, the roles a new account is given