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 noRoleGrant.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
EmailVerifiedAtis 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.
JoinByDomainruns as the sign-in flow, so a derived type needsallow 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
createrule: 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:
IsActivebelongs 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#
- The Accounts add-on — the tier, entitlements, account team and VIPs of a customer organisation — the add-on: the tier, entitlements, account team and VIPs of a customer
- The Identity kit — who signs in, and which of the app's roles they hold —
Principaland the app's own roles - The Invitations kit — invite an address with a role, answered only by that address — inviting a person into an organisation, with a role there
- The UserManagement kit — the user manager: who is in the app, what each may do, and who changed it — the people list that shows and changes memberships
role— a package's name for the app's own roles — how a kit names the app's roles- entity Sub : Base — deriving
CompanyfromOrganization, and the floor it stands on