Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

RoleChoice.HeldIn — where a role may be held: across the app, inside an organisation, or either

new RoleChoice { Role = …, Label = …, HeldIn = RoleScope.App | RoleScope.Organization | RoleScope.Anywhere } · OfferedRoles(RoleScope where) · IsOfferedRole(r, RoleScope where)

Each role an app offers through its pages says where it may be held: across the whole app (a `RoleGrant`), only inside an organisation (the role a membership holds there), or either — the default. The invitation form and the user manager offer a role only where it may be held, and refuse to give it anywhere else, so a team's "Owner" is never handed out as an app-wide role.

preview1 example compiled by CIsecuritykitrolesorganizations

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:

HeldInthe role may be heldoffered and given by the kits
RoleScope.Anywhere (default)across the app, or inside an organisationin both places
RoleScope.Appacross the app only — a RoleGranton an app-wide invitation, and as a person's app role
RoleScope.Organizationonly inside an organisationon 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.
  • Invite refuses 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 with organization: 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.", and GrantInOrganization(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 gets Owner, which manages every organisation in the app."
  • security-tenant-rows-open-to-every-account — a row that belongs to an organisation is granted, with no where, to a caller every account can be: signed in, holding the sign-up role, or a member of some organisation.

See also#

Related

The Identity kit — who signs in, and which of the app's roles they hold

`Osysharp.Identity` is the contract every people-aware kit builds on: `Principal`, the one `[Principal]` of the app (an…

The Invitations kit — invite an address with a role, answered only by that address

`Osysharp.Invitations` brings a person into an app by their address, with what they will be — an app role, or a…

The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it

`Osysharp.UserManagement` is an app's user manager: a searchable people list with each person's roles, two-step status…

The Organizations kit — companies, teams, and roles held within one

`Osysharp.Organizations` holds the groups people belong to — the companies an app's customers work for and the teams…

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…

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…