# 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, 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.

<!-- id: security-people-kit · area: security · stability: preview · html: https://osysharp.com/reference/security/people-kit/ -->

## Summary        {#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:

```osy syntax
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      {#signature}
```osy syntax
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    {#description}

### The people list and a person in full      {#list}
`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            {#record}
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              {#sessions}
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                                  {#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):

```osy syntax
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 `AccessChange`s, 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                              {#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.

```osy syntax
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       {#examples}

### A clinic's people page                    {#example-clinic}
```osy title="who manages people, the roles they may hand out, the last manager kept, and the page" test app=security-people-kit
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       {#see-also}
- [The Invitations kit — invite an address with a role, answered only by that address](https://osysharp.com/reference/security/invitations-kit/) — the invite form the people manager brings
- [The Organizations kit — companies, teams, and roles held within one](https://osysharp.com/reference/security/organizations-kit/) — memberships and roles held within one organisation
- [The Identity kit — who signs in, and which of the app's roles they hold](https://osysharp.com/reference/security/identity-kit/) — `Principal`, `RoleGrant`, and the app's roles
- [RoleChoice.HeldIn — where a role may be held: across the app, inside an organisation, or either](https://osysharp.com/reference/security/roles-held-in/) — a role given only where it may be held: across the app, or inside an organisation
- [Security.* — hashing, verifying, tickets, random ids](https://osysharp.com/reference/stdlib/security/) — `Security.SignOutEverywhere` and `Security.SignedInElsewhere`
