Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

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

use Osysharp.Organizations@0; · entity Company : Organization { … } · allow read where IsMemberOf(Company) · HoldsRoleIn(Company, AppRole.Buyer)

`Osysharp.Organizations` holds the groups people belong to — the companies an app's customers work for and the teams its own people are in: memberships that remember where they came from, roles held WITHIN one organisation (the app's own roles, scoped), and mail domains that place a person with a PROVEN address in the right company. The service a customer organisation gets — its tier, entitlements, account team and VIPs — is its add-on, Osysharp.Organizations.ServicePlans.

preview1 example compiled by CIsecuritykitorganizations

Source on GitHub: Osysharp.Organizations — github.com/osysharp/organizations ↗

Summary#

use Osysharp.Organizations@0; gives an app Organization to derive its companies and teams from, Membership for a person's place in one, MembershipRole for a role held there and nowhere else, and OrganizationDomain so that a person whose address on acme.com is proven joins Acme when they sign in. The app says one thing — who looks after them:

policy ManagesOrganizations => IsStaff;

Signature#

policy ManagesOrganizations;                                  // the app supplies it
[Authority(Extensible)] public entity Organization {
  string Name; Organization? Parent; bool IsActive = true;
  string? ExternalId; IdentitySource Source;
}
[Authority(Extensible)] public entity Membership {             // one per person and organisation
  Organization Organization; Principal Member;
  MembershipSource Source;   // Local | Domain | Invitation | Idp | Scim
  string? ExternalId; bool IsActive = true; DateTime JoinedAt;
}
[Authority(Extensible)] public entity MembershipRole { Membership Membership; role Role; }
public entity OrganizationDomain { Organization Organization; string Domain; bool JoinsAutomatically = true; role? JoinRole; }

policy IsMemberOf(Organization o);          policy HoldsRoleIn(Organization o, role r);
Membership Join(Organization org, Principal person, MembershipSource source = MembershipSource.Local);
void Leave(Organization org, Principal person);                       // the bare step — unrecorded
MembershipRemoval RemoveMember(Organization org, Principal person);   // recorded, mailed, the app told; or leaving
public entity MembershipRemoval { Organization Organization; Principal Member; Principal RemovedBy; MembershipSource Source;
                                  DateTime At; MembershipRemovalState State; string? Refusal; }
MembershipRole GrantIn(Organization org, Principal person, role r);   void RevokeIn(Organization org, Principal person, role r);
bool HoldsIn(Principal person, Organization org, role r);            List<Organization> OrganizationsOf(Principal person);
OrganizationDomain AddDomain(Organization org, string domain, role? joinRole = null);
Organization? OrganizationByDomain(string email);   Membership? JoinByDomain(Principal person);

Description#

Companies and teams#

Derive the kinds the app has — Company : Organization for customers, Team : Organization for its own staff — or use Organization as it is. Parent makes a group of companies or a department of teams. An organisation that is not active is kept with its people and history. A derived type states its own security { } grants (they are never inherited), and this in a rule is the row itself, so "its own people see it" is allow read when IsAuthenticated where IsMemberOf(this);.

Memberships, ready for a directory#

A Membership is one person in one organisation, and is never re-pointed at another of either. Source says what made it and ExternalId holds the provider's id, so single sign-on or a SCIM feed later adds, suspends (IsActive = false) and removes only the memberships it made. A suspended membership holds nothing: no policy of the kit reads it.

A role held in one organisation#

GrantIn(company, person, AppRole.Buyer) makes a MembershipRole — a member of the app's own [Role] enum (role — a package's name for the app's own roles), held at that company and nowhere else. Ask for it in a rule with HoldsRoleIn(Company, AppRole.Buyer).

  • It is never an app-wide role. It is a row of its own table, never a RoleGrant, so no RoleGrant.Any(…) policy can read a role somebody holds at Acme as one they hold everywhere.
  • Nobody grants themself one — not an organisation's own admin, not the app's staff — and Authenticator, the sign-in flow's role, is never one. A role row is made or removed, never edited into another.

Mail domains#

AddDomain(company, "acme.com", AppRole.Viewer) says acme.com belongs to that company. JoinByDomain(person), called from the app's sign-in, places a person whose address is on it — holding the domain's role there, if it names one.

  • Only a proven address joins. An address typed at sign-up proves nothing; a membership made by a domain is refused for an account whose EmailVerifiedAt is not set, whoever tries to make it.
  • Which domain is whose is the app's to say — its organisation managers', never an organisation's own people's. One domain belongs to one organisation, and a public mail service's domain (gmail.com, outlook.com, …) is refused.
  • The sign-in flow must be able to read the organisation. JoinByDomain runs as the sign-in flow, so a derived type needs allow read when IsAuthenticator; — without it the company is invisible to the flow and nobody joins (the kit logs a warning naming the type).

Taking a person out, or leaving#

RemoveMember(company, person) takes a person out of an organisation — or, when person is the caller, is them leaving it. It records a MembershipRemoval (whom, by whom, when, and what kind of membership it ended), and that record's run, in the same commit, deletes their roles there and their membership, calls the app's MembershipEvents.Removed (where the app ends what it hangs on the membership — a seat, access to the organisation's own things) and mails them who removed them and that their account remains.

app.Organizations = new OrganizationsSetup {
  KeepOneOf = AppRole.Owner, KeepOneOfCalled = "owner",   // "Ada is the last owner of Acme — make someone else its owner first."
  Events = new DeskMemberships(),                         // override Removed(MembershipRemoval removal)
};
  • Who may do it is the removal's own create rule: the app's organisation managers, the person themself, and whoever the app grants (partial entity MembershipRemoval { security { allow create where HoldsRoleIn(Organization, AppRole.Owner); } }). The recorded remover is always the caller.
  • Removal is not erasure. The account and everything else the person belongs to stay. The membership is deleted, not suspended: IsActive belongs to the identity provider or directory that made a membership, so one made by single sign-on or SCIM is refused here, with the sentence saying to remove them there.
  • Their access ends at once — the next request they make reads nothing the membership gave them — and a person taken out does not walk back in through the organisation's mail domain; an invitation, or Join, brings them back.
  • Leave(org, person) is the bare step — unrecorded, nobody told — for whatever keeps memberships in step with an outside source.

The service a customer gets#

The tier an organisation you serve is on, what it is entitled to, its account team and its VIP people are the add-on Osysharp.Organizations.ServicePlans (The Accounts add-on — the tier, entitlements, account team and VIPs of a customer organisation), which uses this kit: derive a customer from its CustomerAccount (itself an Organization) and keep your own teams plain organisations.

Examples#

A supplier portal: buyers at their own company#

app SupplierPortal {
  use Osysharp.Identity;
  use Osysharp.Organizations;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Organizations;

[Role] enum PortalRole { Authenticator, Staff, Buyer, Viewer }
policy IsStaff => RoleGrant.Any(g => g.Member == user && g.Role == PortalRole.Staff);
policy ManagesOrganizations => IsStaff;

entity Person : Principal {
  [MaxLength(200)] string CodeHash = "";
  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 CodeHash when !IsAuthenticator;
  }
}

entity Company : Organization {
  [MaxLength(40)] string AccountNumber = "";
  security {
    allow read, create, update when IsStaff;
    allow read when IsAuthenticator;
    allow read when IsAuthenticated where IsMemberOf(this);
  }
}

entity PurchaseOrder {
  [Required("Say which company the order is for.")] Company Company;
  [MaxLength(80)] string Reference = "";
  security {
    allow read when IsStaff;
    allow read where IsMemberOf(Company);
    allow create where HoldsRoleIn(Company, PortalRole.Buyer);
  }
}

Company OpenAccount(string name, string domain) {
  var company = new Company { Name = name };
  AddDomain(company, domain, PortalRole.Viewer);
  return company;
}

[AuthMethod]
string SignInWithCode(string email, string code) {
  var p = Person.FirstOrDefault(x => x.Email == NormalisedEmail(email));
  if (p == null || !p.IsActive) { Security.VerifyPassword(code); return ""; }
  if (!Security.VerifyPassword(code, p.CodeHash)) { return ""; }
  JoinByDomain(p);
  return Security.IssueJwt(p.Id, p.Email);
}

app.AuthBootstrap = new AuthBootstrap { Role = PortalRole.Authenticator, Login = SignInWithCode };
app.Auth = new PasswordAuth { LoginField = Email, PasswordField = CodeHash };

See also#

Related

The Accounts add-on — the tier, entitlements, account team and VIPs of a customer organisation

`Osysharp.Organizations.ServicePlans` adds what a vendor keeps about the organisations it SERVES to the base…

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…

`role` — a package's name for the app's own roles

A package cannot know an app's `[Role]` enum, and an app has exactly one. `role` is how a package names it anyway: a…

entity Sub : Base

Derives one entity from another. The subtype is its own type with its own name, its own security and its own workflows…

[Authority]

What an app that imports your entity may do to the `security { }` you declared on it. The default is `Narrowable` — the…