Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

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

use Osysharp.UserManagement@0; · policy ManagesPeople => IsAdmin; · PeopleManager(canInvite: MayInvite) · GrantRole(person, AppRole.Lead, "reason")

`Osysharp.UserManagement` is an app's user manager: a searchable people list with each person's roles, two-step status, proven address and last sign-in; a person in full, with where else they are signed in and the history of their access; and the changes a manager makes — grant and revoke roles, deactivate and reactivate, reset a lost second factor, sign out everywhere, organisation memberships and roles, API keys and integrations (programs that work under their own name and can never sign in) — each one recorded before it is applied, so the trail is the only path.

preview1 example compiled by CIsecuritykitpeopleadmin

Source on GitHub: Osysharp.UserManagement — github.com/osysharp/usermanagement ↗

Summary#

use Osysharp.UserManagement@0; gives an app the page its administrators look after people on, and the functions behind it. The app says who manages people and which of its roles a manager may hand out:

policy ManagesPeople => IsAdmin;

It is a kit of its own rather than part of Osysharp.UserAccounts because it administers over any Principal, whatever signs them in, and it needs Osysharp.Invitations and Osysharp.Organizations, which themselves build on Accounts.

Signature#

policy ManagesPeople;                                         // the app supplies it
app.People = new PeopleSetup { KeepOneOf = AppRole.Admin, ReasonRequired = false, Directory = new PeopleDirectory() };
public entity AccessChange {                                  // the trail — made by managers, never edited or deleted
  Principal Person; Principal By; AccessChangeKind Kind; role? Role; Organization? Organization;
  string Reason; DateTime At; AccessChangeState State;   // Requested | Applied | Refused
  string? Refusal;
}
AccessChange GrantRole(Principal person, role r, string reason = "");   AccessChange RevokeRole(Principal person, role r, string reason = "");
AccessChange Deactivate(Principal person, string reason = "");          AccessChange Reactivate(Principal person, string reason = "");
AccessChange ResetSecondFactor(Principal person, string reason);        AccessChange SignOutEverywhere(Principal person, string reason = "");
AccessChange AddToOrganization(Organization org, Principal person, string reason = "");
AccessChange RemoveFromOrganization(Organization org, Principal person, string reason = "");
AccessChange GrantInOrganization(Organization org, Principal person, role r, string reason = "");
AccessChange RevokeInOrganization(Organization org, Principal person, role r, string reason = "");
List<PersonRow> PeopleList(string search = "", bool includeInactive = true);
PersonDetail PersonDetailOf(Guid id);      List<AccessEvent> AccessTrail(int take = 100);
[Composable] component PeopleManager(bool canInvite = true, bool invitations = true);
[Composable] component PersonPanel(PersonDetail d, Action onChanged, Action onClose);
[Composable] component AccessTrailList(int take = 100);

Description#

The people list and a person in full#

PeopleManager lists people — search by name or address — with each one's roles by the names app.Roles gives them, whether two-step sign-in is on, whether their address is proven, and when they last signed in; deactivated people are kept and marked. Opening one shows their app roles, the roles they hold in organisations, how many other browsers they are signed in on now, and every change to their access, with the invitation they joined by. On a wide screen the person opens beside the list; on a narrow one, in its place. It brings the invite form and the waiting invitations with it — invitations: false leaves the waiting ones out, for a page that lists them on a tab of their own (InvitationList).

PeopleSetup.Directory decides who is listed — everybody with an account, the first 500 by name (a search narrows it). When the app's customers sign in too, derive PeopleDirectory and override Listed() to read only the team — public override List<Principal>? Listed() { return Principal.Where(p => p is Agent).ToList(); } — so the people page never reads a customer to leave them out. To leave out only a FEW accounts — programs that have a page of their own — override Hidden() instead: public override List<Guid> Hidden() { return Robot.Select(r => r.Account.Id).ToList(); }. The database leaves those ids out, and the list goes on searching everybody else and taking them a page at a time. The same list is who an API key can be issued to, beside the integrations.

The kit reads who holds which role, so the app grants that read, and nothing more: partial entity RoleGrant { security { allow read when ManagesPeople; } }.

Every change is a record first#

A manager never writes somebody's access directly. Each change is an AccessChange — who made it, to whom, what, when and why — and its run applies it in the same commit. Only the app's people managers may make one, naming themselves; nobody edits or deletes one. So the trail of who changed whose access is not a log that might be missing a path: it is the only path.

The run checks every change before it applies it, and refuses — with the reason, changing nothing — one that breaks a rule:

  • a role is granted only if the app offers it (app.Roles) — never Authenticator, never a role left unlisted;
  • a manager never deactivates themself, and the app is never left without an active holder of KeepOneOf;
  • a second factor is reset only with a reason written down (every change, with ReasonRequired).

Sessions end with the change#

Deactivating, resetting a second factor and signing out everywhere end every session the person has, on every server ([[stdlib-security#sign-out-everywhere|Security.SignOutEverywhere]]). Each ended session is told "You were signed out because somebody who looks after this app's people changed this account's access. Sign in again." — not that its password changed. Resetting a second factor also clears the authenticator, the recovery codes and a texted step, and mails the person; they sign in with their password and set it up again.

API keys#

A people manager can issue a teammate an API key, so that a program (a script, an integration tool) can call the app's REST API as that person, with exactly what that person may do. The page is /settings/api-keys in the app's settings area (ApiKeysSettings, or ApiKeyManager() on a page of your own):

IssuedApiKey IssueApiKey(Principal person, string name, string reason = "");   // .Key is shown ONCE
AccessChange RevokeApiKey(ApiKey key, string reason = "");
public entity ApiKey { Principal Holder; string Name; Principal IssuedBy; DateTime IssuedAt; string Ending;
                       DateTime? RevokedAt; Principal? RevokedBy; }
[Composable] component ApiKeyManager();
  • Shown once, kept as a hash. The key (pk_…) is answered to the manager who issued it and kept nowhere. Its hash goes in one of the holder's two key slots (Principal.ApiKeyHash / ApiKeyHash2), so a person can hold two keys at a time and a key can be replaced with no gap. The list shows each key's last four characters.
  • A record first. Issuing and revoking are AccessChanges, like every other change to somebody's access, and are applied by their run in the same commit. The access trail reads Issued the API key “Nightly export” …x7Qa.
  • It acts as its holder. A request presenting the key is that person, under the app's own security {}. Nothing about the key widens what they may do.
  • Last used. The platform records when each key was last presented, to the minute, and the list shows it.
  • Revoked at once. A revoked key is refused from the next request. Deactivating a person revokes every key they hold, including one an operator minted with osyrin app user apikey generate.

Only people managers issue and revoke keys. A person can see the keys they hold, but cannot read one back or write their own key slots.

Integrations#

A program that works with the app — Zapier, a nightly export, a partner's system — should do it under its own name, not a teammate's. An integration is an account of its own: a name, the role it acts with, the person who answers for it, and API keys. It has no mailbox and no password, and it can never sign in.

Integration AddIntegration(string name, role r, Principal? owner = null, string reason = "");   // owner: the caller
AccessChange ReassignIntegration(Integration integration, Principal owner, string reason = "");
public entity Integration : Principal { Principal Owner; }
List<IntegrationLine> IntegrationLines(bool withInactive = false);
  • Under its own name. Everything it does through the API is recorded as its own — a note it leaves is signed with its name, and the access trail reads Added the integration “Zapier” as Agent, owned by Sam.
  • Never signs in. No sign-in door admits it: it holds no password, and its address is unique and receives no mail. It acts only through a key a people manager issued it. Nobody edits it afterwards — itself included, holding its own key — so the name on what it did stays true.
  • A record first. Adding one is an AccessChange (IntegrationAdded) that its run applies, making the account and granting the role — one the app offers — in the same commit. The app is told the role was given (RolesSetup.Events.Granted) as for anybody; an app that puts new holders of a role to work (a desk's queues) checks person is Integration and leaves it out.
  • Switched off at once. Deactivate revokes every key it holds, from the next request.
  • It outlives its owner. Deactivating the person who answers for it ends their sessions and their keys, never the integration's, so a program the team depends on does not stop because somebody left. Its line says so (IntegrationLine.OwnerActive), and a people manager gives it to somebody who is here with ReassignIntegration: an AccessChange (IntegrationReassigned) like any other, refused for a deactivated account, another integration or the person who already answers for it. The trail reads Gave the integration “Zapier” to Sam, and its keys and role are untouched.
  • Never the last admin. An integration holding the role the app must keep (PeopleSetup.KeepOneOf) does not count as its holder, so a program cannot be the app's only admin.

On the API keys page (ApiKeyManager), Add an integration asks for its name and what it may do and shows its first key once; each integration is listed with its role, its owner and its keys, with Reassign and Switch off beside it. An integration whose owner was deactivated is marked Owner deactivated, and Reassign asks who answers for it from now on, and why.

Examples#

A clinic's people page#

app Clinic {
  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;
  use Osysharp.UserManagement@0;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.UserAccounts;
using Osysharp.UserManagement;

[Role] enum ClinicRole { Authenticator, Nurse, Doctor, Manager, Integration }
policy IsManager => RoleGrant.Any(g => g.Member == user && g.Role == ClinicRole.Manager);
policy ManagesPeople => IsManager;
policy MayInvite => IsManager;
policy ManagesOrganizations => IsManager;

app.Accounts = new AccountsSetup { AppName = "Clinic", SignupOpen = false, FirstAccountRole = ClinicRole.Manager };
app.Roles = new RolesSetup { Offered = [
  new RoleChoice { Role = ClinicRole.Nurse, Label = "Nurse", Description = "Sees the day's patients." },
  new RoleChoice { Role = ClinicRole.Doctor, Label = "Doctor", Description = "Writes notes and prescriptions." },
  new RoleChoice { Role = ClinicRole.Manager, Label = "Manager", Description = "Looks after the clinic's people." },
] };
app.People = new PeopleSetup { KeepOneOf = ClinicRole.Manager, ReasonRequired = true };

partial entity RoleGrant { security { allow read when ManagesPeople; } }

AccessChange CoverNights(Principal nurse) { return GrantRole(nurse, ClinicRole.Doctor, "covering nights in December"); }

// The booking system, as an account of its own — it acts as a nurse, and the clinic manager answers for it.
string ConnectBookings() {
  var bookings = AddIntegration("Booking system", ClinicRole.Nurse, reason: "Appointments arrive from the booking site");
  return IssueApiKey(bookings, "Appointments feed").Key;   // shown once — hand it to the booking system now
}

[Route("/")]
[Title("People")]
component PeoplePage() {
  render {
    Stack(gap: 5, p: 5, canSee: ManagesPeople) {
      PeopleManager(canInvite: MayInvite);
      Card("Who changed what") { AccessTrailList(take: 50); }
    }
  }
}

The help desk sample uses it for its Settings → People and API keys pages, listing only its agents.

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 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, …)…

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 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…

Security.* — hashing, verifying, tickets, random ids

The calls an authentication flow needs: `HashPassword` (salted, one-way), `VerifyPassword` (constant-work comparison…