Groups and the attribute schema
Applies to: SimpleTwo 0.9.x, directory 0.2 · Verified: 24.09.2026
Groups, address books, mail aliases and the attribute schema live in a server role of their
own, identity — its own Postgres database, its own host. All of it is managed in the coordinator's console: people, guests, groups and bots under
Users; sign-in, applications and synchronisation under Security. In the platform console the Directory card (Settings → Directory) shows only the
role's state: the host, the version, the public name, the account reconciliation, and a link to
the portal /me (from 26.09.2026, version 0.9.286 and later; before that the
same blocks were on the card too).
When the card is empty
"no directory host yet" — no host is enrolled for the role. Enrolment is the calendar's:
Servers → role identity, a private address is required (the coordinator and the services
reach the role on it), a public name is optional. The role's store is embedded SQLite files on
its own host (/var/lib/simpletwo/directory, since directory 0.76.0): it needs no database on
the postgres role. An installation whose directory data lived in PostgreSQL moves it by
itself at the first start of 0.76.0 and never reads that database again.
Reaching directory from outside
A public name is what the directory sign-in, the portal /me and the console /admin need.
With it, the service obtains its own certificate on 443. The Reach field at enrolment: direct
— the name points at the host's own public address; nat — at the organisation's address, whose
NAT forwards 443 to this host. The DNS record for the name is yours to create. Clients are handed
the name only once the coordinator has reached it; the line under the card's heading shows the
last check: what the name resolves to, whether it is announced, and why not.
Kinds of groups
| kind | who owns the membership | for |
|---|---|---|
synced | the identity provider (ADFS/AD) — written at every sign-in, read-only here | roles, gateways, address-book policy |
unit | a rule over attributes: company, department, site | @dept, a team's calendar, an audience for rules |
managed | a list from the console, or a rule | on call, reviewers, a project's people |
resource | the rule entity: resource | rooms in the booking picker |
published | a list; visibility federated laid down, switched on with the federation phase | "our contacts at X" |
personal | the person; visible to them alone | favourites, "my team" |
system | the platform; not deletable | role groups |
The kind cannot change after creation: a group of another kind is another group.
A dynamic group's rule
Directory computes a dynamic group's membership itself: the coordinator sends it every directory event together with the whole profile, the rule is re-evaluated for that person, and membership rows are added or removed. Changing the rule recomputes the group over everybody directory already knows; a rule saved before the first directory event stays empty until the nightly reconcile — the log says so. Such a membership is never edited by hand.
rule is a map of key to value; every pair must match. The keys are what an account really
carries: the schema's attributes plus user, display_name, is_guest, entity. entity
takes person, service, resource, a comma-separated list, !resource ("everybody except
the rooms") or any.
A key nothing carries is refused at save, naming the key and listing the ones that exist.
Not pedantry: on 08.09.2026 a welcome rule was narrowed on is_room: false — no such
variable existed, the rule stopped matching anybody, and for ten days it silently greeted
nobody while the console showed it enabled.
Who may address a group
writers — who may mention the group, write to its address, ring it: user:<name>,
role:<name>, group:<id>. Empty means anybody who can see the group. There is no ceiling on
reach: @sales in a company of four thousand is four thousand notifications, and the control
is who may, not how many it hits. owners may edit the membership, writers and join
without being administrators. address makes the group a distribution list — of any kind,
dynamic included.
Messaging enforces this at send time: a message with the @handle of a group the sender may
not address is refused whole, with the code group_not_addressable — it does not go out "as
text" with the mention quietly dropped. For those who may, @handle counts as a mention of
every member: a notification at the "mentions only" level, a Do-Not-Disturb breakthrough,
auto-follow in a thread. Messaging receives its copy of the group from directory after every
edit; between the edit and the copy there are seconds, and if messaging was unreachable the
change arrives with the next edit of that group.
In automation rules a group is one more when condition: group: <id or @handle> beside
entity narrows the rule to the group's members at the moment of the event.
Owners and self-service
owners — people (user:<name>) or a role (role:<name>) who run the group without being
administrators: they change its name, writers, join and default_convs, add and remove
members, and set a membership's term (expires_at — a contractor until Friday, a stand-in on
call). The group's kind, rule, source, address and the owners list itself stay with the
administrator — those change what the group is, not who is in it.
join decides how people get in: closed (the default) — an owner adds them; approval — a
person asks, the owners named by name are notified and decide in the request queue; open —
anybody who can see the group walks in. Any member may leave. A lapsed membership is removed
on its own within ten minutes and appears in the audit as group.member.expire.
default_convs — conversations a new member is added to on appearing in the group, by
whichever path: an owner, an approved request, a sync from AD. A conversation that does not
exist is skipped with a log line; the group itself still updates.
The attribute schema
Every attribute is one definition: kind (text, number, date, birthday, phone, address, url,
choice, multichoice, user, bool), source — exactly one writer (self, admin, claim, ldap,
hrms, scim), visibility (everyone, unit, managers, admins, self), searchable, required, the
values of a choice, pattern. The LDAP and SCIM names are filled in with our own prefix
(x-simpletwo-…, …:extension:simpletwo:2.0:User:…) unless you name standard ones.
What the schema refuses, so that it stays a schema:
- a kind change — widening
texttochoicewould silently invalidate every stored value; retire the definition and define a new key; - removing a choice a value may carry;
- deleting a definition — only
Retire: values stay readable to administrators, the key stays reserved,Restorebrings it back; - computed attributes ("full name from two fields") — a stored derivation goes stale with nothing saying so.
A definition added here is a valid key in group rules and automations the same moment.
The base set. The schema ships with a base after SCIM 2.0 (RFC 7643): the core (nickname,
photo, the name in parts — given_name, family_name, middle_name, phones, job title,
preferred language), the Enterprise extension (department, company — SCIM's organization,
division, employee_number, cost_center, manager) and the platform's extension (office,
status, hire_date, birthday, the two HRMS managers, nativefullname — the full name in
the native language). LDAP names are the standard ones where they exist; the rest sits under the
x-simpletwo-… prefix. The directory seeds the base on start and never overwrites your edits.
The photo, when the photo definition's source is claim, is taken from the ID token's claim
(thumbnailPhoto with ADFS) at every sign-in through the provider and stored like a photo from
LDAP; the LDAP photo pass stands down then.
SCIM. A provisioner — Entra, Okta, an HRMS — writes and reads people at /scim/v2 by these
definitions (directory 0.91.0 and later): whatever a definition places is kept on the way in and
given back on the way out — the name in its parts, the job title, the work and the mobile number,
the Enterprise attributes and the platform's own; /scim/v2/Schemas describes them with their
types. A standard attribute no definition places (an address, a fax number) is not kept, and the
audit names it; an Enterprise or platform attribute nobody defined is refused until you define
it. A PUT clears an extension's attributes only when it carries that extension, and leaves alone
what SCIM does not carry. Filters take RFC 7644's whole grammar on any attribute. Pushing to an
application sends the core and the Enterprise attributes, the manager by the application's own id.
From directory 0.94.0 a person's place in the organisation's tree is served too, read-only, as
orgUnit (the unit's id, name and path) under the platform's own schema; filtering on
orgUnit.path selects a subtree.
Whole groups from Active Directory
A sign-in through ADFS brings only the names of the groups the signing-in person is in. For a
directory that is complete before the first sign-in — an owner on every group, the full
membership — switch the LDAP source on: in the console, under Security → Synchronisation, the LDAP source — the directory's jobs card → Edit the
source → group_base_dn (for example OU=Groups,DC=corp,DC=example; the default filter is
(objectClass=group)). Every six hours and on Sync groups now directory reads group objects
and people in paged searches, matches each member to an account — by userPrincipalName,
then mail, then sAMAccountName; the unmatched are counted, never guessed — names
managedBy as the owner, and writes the group under the same id a sign-in mints, so an AD group
learned of twice stays one group. Two AD groups whose names reduce to one id (case, punctuation,
the cut at 48 characters) are told apart by objectGUID: the id stays with the group already
holding it, and the other gets a stable suffix from its GUID. A group in which nothing changed is
not rewritten. The last
pass line on the card shows groups, written, memberships, unmatched, owners — or the error.
Where the directory's jobs run. The group, people and photo passes and the SimpleOne HRMS module are directory's: directory is the directory and the issuer. The coordinator used to run them, and its settings move by themselves: as soon as a directory host is deployed in the installation, the coordinator hands it, once, the LDAP connection with its bind password, the incremental read's watermarks, the last results, the SimpleOne connection with its secret and the HRMS settings, and after directory's answer deletes its own copies — each secret has one owner. directory keeps the secrets sealed, like its other integration secrets. After the move the "Directory photos (AD / LDAP)" and "SimpleOne (HRMS)" cards in the platform console say "Managed on directory" and link to Security → Synchronisation; the "let everyone upload their own photo" switch stays on the platform, and directory is told its value. Without directory (a small installation) the jobs stay on the coordinator and are set on its cards as before; the same holds while directory is too old to take them, and the card says so.
People from the directory. On the same card — Sync people now and a pass every six
hours: each account here takes from the directory the attributes the directory schema declares
with source: ldap and an ldap_name (and only those — an attribute the schema does not
know is never stored under its AD name), and its manager — the manager DN becomes the name
of an account here. Standing (the disabled bit, accountExpires) is counted on every pass
and applied only with the "apply standing" switch on: it is the one part of the pass that
can lock a person out, so look at the number of differences first. Changes travel as ordinary
directory events — to messaging and to directory's people projection — so a dynamic group on
department moves the same hour AD does. directory writes the change straight into the account of
record, and the coordinator takes it from the account change feed, as it takes SCIM's writes.
Leavers. LDAP does not announce a deletion, so the people pass finds the accounts that came from the directory (ADFS sign-in) and are no longer in it, and names them on the card. With the "apply standing" switch on they are blocked. If there are more than a tenth of them (or more than half in a small organisation), or the directory returned nobody, the pass blocks no one and says why — that is a broken base DN or filter, not a wave of departures. Local accounts and guests are never touched.
Changes only. Against Active Directory the people pass reads only what changed since the last
time (by uSNChanged on the same domain controller); the whole directory is read on the first pass,
when the controller changes, when the profile or the mapped attributes change, and once a day. A
person deleted from AD is seen at once if the service account may read CN=Deleted Objects;
otherwise on the daily full read. The card says which kind the last read was. The group pass reads
the same way: only the groups that changed (a membership change is a change of the group); a group
deleted in AD is removed from directory by its tombstone in CN=Deleted Objects or on the daily full
read, and a renamed one is recognised by its objectGUID. Only groups this pass itself wrote are
removed, and no more than a tenth at a time.
Passwords during coexistence. A directory never exports a password hash, so the same
card has a password pass-through switch: while an account has no local credential,
directory decides its password by binding to the directory as the person — over ldaps://
only; directory refuses a profile with ldap://. Nothing is stored: local credentials
accumulate as people enrol them, and the Directory card counts how many still have none —
the number the day the directory is switched off is planned by. A wrong password counts
toward the lockout like a local one; a directory that does not answer is not a refusal but
the message "the company directory did not answer", and it counts against nobody. The switch
itself is on the LDAP source card under Security → Synchronisation; the connection profile lives in one place —
directory, on the LDAP source — the directory's jobs card (before the jobs move, the platform console's card, from which it
is copied to directory).
Photos from the directory. A person's photo (thumbnailPhoto, or the attribute the source
names) is read by directory at their sign-in, for the people an incremental pass saw change, and on
Sync all photos; directory keeps it — at most 512 KB, and only JPEG or PNG, checked by content —
and hands it to messaging, which serves the avatars. An unchanged photo is not sent again; a photo
removed from the directory is removed in directory and in messaging. Only accounts from the
directory (signed in through the company provider) have one; everybody else uploads their own.
While the platform's "let everyone upload their own photo" is on, or the photo arrives in an
ID-token claim, the directory leaves photos alone.
SimpleOne HRMS. On the same tab: the connection to the SimpleOne instance (https:// only,
Basic or Bearer; the password or token is kept sealed, and moving the instance to another address
needs it entered again — a secret never goes to a new host; the same holds for the LDAP bind
password) and the HRMS module — the path to the employee table, the
field map (attr_map), the schedule map (schedule_map: a schedule's name → days and hours), the
interval and dry run. Preview writes nothing and shows how many records matched, what would
change and which schedule names the instance answers with; Sync now works only with the module
on and dry run off. Attributes that feed the platform's sign-in claims are not written by HRMS —
the report lists them. The working hours the schedule map translates a person's schedule to are
written on the account, and the platform hands them to the calendar.
Address books
Personal books are stored on the server for the sake of a second device, but they are not
shown in the console and cannot be read through the coordinator — there is no route to
another person's book for anybody. The organisation's book (owner = org) is shared: external
partners, suppliers, hotlines; administrators maintain it.
Import and export. A book takes vCard (3.0 and 4.0) and CSV and gives back the same:
POST …/books/{book}/import with the file as the body, GET …/books/{book}/export?format=vcf|csv.
A contact here is a pointer (an address, a number, a user) plus a label and a note, so a card
contributes its EMAIL, TEL and X-SIMPLETWO-USER, FN becomes the label and NOTE the note;
everything else (ORG, ADR, photo) is counted in the report as dropped_properties, and a card
with no pointer at all lands in refused with its line number. Duplicates by "kind + target"
against what the book already holds are counted, not written; importing again — the book's own
export included — creates nothing. An imported contact's id is a keyed MAC of the book and the
pointer under the service's secret, so a database dump cannot test guessed addresses or numbers
against it; contacts imported before keep their earlier ids. CSV: a header of kind,target,label,note,pinned in any
order, , or ; as the separator. The audit line carries counts only.
The global address list: who sees whom
Whom a person finds in search, sees in the contacts list and may start writing to is decided by
the directory role from address-book policies, the way Exchange does it. The coordinator
console, Users tab, Address book card.
- An address list is a filter over the directory. Its conditions are AND-ed: members of the
listed groups; the account kind (
member,guest,partner,bot,system,resource); attribute values; "shares a group with the reader". A list with no condition is the whole organisation. - An address-book policy is a set of lists and the groups it is assigned to. It is assigned to groups only, never to roles.
- A person sees the union of the lists of every policy their groups carry. When none of their groups carries one, the default policy applies — the built-in one, the whole organisation.
- A guest and the employee who invited them always see each other, whatever the policies.
- People one already shares a conversation with but whom one's lists leave out are found as a name only: no title, no phones, nothing else of the card.
- An account with "Show in the address book" cleared is found by its exact address only.
A conversation may be started with somebody the address book shows in full, or somebody one
already shares a conversation with; messaging asks directory and answers not_reachable
otherwise. What does this person see shows the address book of a given login — check a policy
with it before assigning it to a group.
After upgrading to the release with policies, employees see what they saw before. Guests and partners whom a role used to limit see the whole organisation until a policy is assigned to their group: assign one to the guests' group (the built-in «Свои группы» — people who share a group with the reader — for example) right after the upgrade.
Mailboxes
Directory keeps no mailboxes. Every mailbox — personal, shared, resource — and the access to it belong to the mail role. It takes people and groups from directory and decides itself who may open which mailbox. Calendars and address books belong to the person's account.
Credential policy and the help desk
In the console, under Security → Sign-in requirements, the Credential policy card: minimum length, no reuse of the last N, expiry in
days (0 by default — never: expiry without evidence of compromise is harm, but an auditor may
require it), lock after N misses for M minutes, and the leaked-password check. The check is
local: a shipped list of the most common passwords is always there, and the directory host
may name DIR_BREACH_LIST — the Pwned Passwords export (SHA-1, ordered by hash); the file is
searched by bisection and never loaded, and nothing leaves the role. A password moving over from
the coordinator at first sign-in is not judged — the person already has it.
In the same place, the Sign-in requirements block (all off by default). Require a second
factor (mfa_required): off, for roles (mfa_roles; empty = the roles that open the console) or
for everyone. A person in scope without a second factor who signs in with a password is taken to a
set-up step right on the sign-in page: the secret for the authenticator app and a code, or a new
passkey, then the recovery codes. The grace period (mfa_grace_days, 0 = at the next sign-in)
counts from the day the requirement first applied to the person; while it lasts the step can be
postponed. If the only factor is a passkey, it is asked for after the password. The messenger's
password sign-in holds the same requirement by refusing: after the grace no token is issued, and
the answer {"mfa_enrolment_required": true, "portal": "<identity>/me"} sends the person to the
portal. A sign-in through the company's provider is outside it: the provider owns those factors. For
one application, options.require_mfa: true in its options: a one-factor sign-in (a password
without a code, a passkey without verification on the device, a live session made so) is stepped up
with the second factor before the code is issued. The application sees how the person signed in in
the ID token: amr (pwd, otp, hwk/swk, mfa) and acr (urn:simpletwo:mfa or
urn:simpletwo:1fa). Sign in with passkeys only (passwordless) is the
identity provider's policy, not the directory's, which refuses it.
In the same place, Forgot password? on the sign-in page (self_reset: off by default, or
on). An eight-digit code arrives in the SimpleTwo app on the devices where the person is already
signed in — a channel that whoever knows only the username cannot read; nothing is sent to an
address typed on the page, and the code never goes by e-mail. The page never says whether the
account exists. The code works for 10 minutes, once, five tries; with a second factor enrolled it is
asked after the code (or a recovery code). Company-directory accounts (LDAP, a domain of the
company's provider) are not reset here — the password is changed in the directory; neither is an
account with passkeys and no code: it signs in with the passkey and changes the password in the
portal. At most three codes an hour per account and ten requests an hour from one address. After a
reset every session and refresh token ends, password.reset.self is written to the log, and the
person gets a notice.
In a user's row menu (⋯), once the directory role is deployed: Unlock, Temporary password (twelve characters without look-alikes, shown once; at the next sign-in the person must set their own), Reset 2FA. "Log out" now also ends sessions on directory's sign-in page and the refresh tokens of applications. Enabling the second factor issues eight recovery codes — once; each is accepted at the factor step instead of the app's code and is consumed. After the password and the factor, directory's sign-in page demands a replacement for a temporary or expired password before it issues a code to the application.
Sessions: how long a sign-in lasts
In the console, under Security → Sign-in requirements, the Sessions card. By default
a session on the sign-in page lasts 12 hours, there is no idle timeout, an application's
(refresh) token lives 90 days from its last use with no overall limit, and the console does not ask
to sign in again. Browser session, hours (web_max_hours, up to 720) is the absolute lifetime,
however busy the person is. Idle timeout, minutes (web_idle_minutes, 0 = off, otherwise 5 to
10080) ends a session unused for that long. Application token unused, days
(refresh_idle_days, up to 365) restarts at every token refresh. Application token at most,
days (refresh_max_days, 0 = no limit) counts from the sign-in the chain began with; a lowered
limit holds for tokens already issued too. Lost refresh answer: retry within, seconds
(refresh_replay_seconds, 120 by default, up to 600, −1 = never). Every refresh gives the
application a new token and ends the old one, and the old one presented again is taken for a copy
and ends the session. When the answer to a refresh is lost (the app went to the background, the
network changed), the app retries with the old token: within this window, while nobody has used the
new token, the retry gets a new pair and the session goes on (audited as oidc.refresh_replay).
Tokens bound to the device key retry with a proof by that key at any time. A retry after the new
token was used still ends the session (oidc.refresh_reuse). Console: fresh sign-in after, minutes
(reauth_for_console_minutes, 0 = off) makes the console ask, for the directory's sections, for a fresh sign-in when the
last one is older. A single application can have its own options.access_ttl_minutes (the access
token's lifetime, 5…1440, an hour by default) and options.refresh_max_days (replaces the policy's
limit) in its options. The Sign out everyone button, after a confirmation, ends every browser
session and application token of the organisation, tells each application with a logout address
(backchannel_logout_uri) about each person, and is recorded in the audit (sessions.revoke_all).
An access token already issued lives out its lifetime.
Applications: rights, consent, services and devices
Under Security → Applications each application has Options — one JSON
document: scopes — what it may ask for (a request outside the registration is refused, never
silently narrowed); options.claims — which of the claims the scope would give it receives;
options.attrs — schema keys sent as claims; options.groups_as — groups by id, handle or
name; options.client_credentials — the grant for a service (confidential only; a token with
sub: client:<id>, no ID token); options.device_flow — sign-in from a device without a
keyboard: the device gets a code from POST /oauth/device, the person types it at
identity_url/oauth/device, signs in as usual and approves, and the device collects the tokens
when it polls. An application not marked first-party asks the person's consent once, on a
page in their language; the portal lists the consents and revokes them (with the application's
refresh tokens).
Access reviews
Under Security → Access reviews:
"review guests older than N days" and "review role holders older than N days" — once a day directory
opens a review: a guest's to whoever invited them, a role holder's to their manager (the manager
attribute); the reviewer is told in the messenger and answers in the portal identity_url/me
(Groups): "still needed" or "revoke". Silence past the due date revokes: a guest is disabled, a
role holder loses every role — a write of record the platform console picks up through the change
feed. Kept access is reviewed again after the same period. Beside it: "lock a source address after N
misses" — the brute-force guard per address, independent of the accounts. A wrong password and
a wrong second-factor code both count as misses — on directory's sign-in page and in the
messenger's sign-in (the coordinator sends the client's address with the code); from a locked
address even a right code is refused.
SCIM 2.0: an HRMS or Entra writes people and groups in
Security → Synchronisation, the SCIM 2.0 card: issue a token (shown once) and give the provisioner identity_url/scim/v2 with the
bearer token. Users and Groups are supported (create, read, the filters userName/externalId/
emails.value/displayName/active, PATCH — active, name, e-mail, extension attributes, group
members); the enterprise extension (department, manager, organization, division,
employeeNumber) lands under the schema keys of the same names, and the
urn:ietf:params:scim:schemas:extension:simpletwo:2.0:User extension under the keys named by their
scim_path; an attribute with no key in the schema is refused by name. A user written there is an
account of record with source: scim; the platform console picks it up within a minute through
directory's change feed, and everything a console-made account gets — the directory event, the
welcome trigger, the assistant, the calendar — follows.
A first load is best sent as one Bulk request (POST /scim/v2/Bulk, up to 100 operations and
1 MB): a group may reference people created in the same request by bulkId:<id>, even when they
are listed below it; an operation whose reference points at a failed create is itself refused
(409), and failOnErrors stops the request after that many errors. Lists sort (sortBy —
userName, displayName, meta.lastModified; sortOrder — ascending/descending). Every
resource has a version — the weak ETag W/"<revision>" in meta.version and the ETag header:
If-Match on PUT/PATCH/DELETE answers 412 when the resource changed meanwhile, If-None-Match on
GET answers 304 when it did not.
The SCIM client: directory provisions applications
The other direction — the console → Security → Synchronisation → SCIM targets: an application that
wants people and groups is added with its base_url (the SCIM 2.0 root, https only), the
bearer token the application issued (never shown back), and what to send: users, groups,
filter_group (only that group's members). A new person goes out as POST /Users, an edit as
PUT /Users/{id} with the id the application returned; a leaver (deleted, or out of
filter_group) is disabled with PATCH active=false, not deleted. A group is created with
POST /Groups, its membership replaced with PATCH /Groups/{id}, a deleted group deleted. The
push follows the same account and group change feeds the platform console and mail read, within a
minute; each target has its own cursor. On a failure the cursor stays on the row that did not go,
and the next attempt waits with a growing back-off (30 seconds to 30 minutes); the table shows the
last success, the last error and how many changes are pending, and "Push now" tries at once. A new
base_url is a new application: the push starts over. Every push and failure is an audit row
scim.push.
The LDAP server for applications
The printer, the Wi-Fi controller, the NAS, a GitLab without OIDC — the console's Security → Synchronisation, the LDAP
server block. A read-only tree: ou=people (uid=<username>, inetOrgPerson: cn, sn, givenName,
displayName, mail, telephoneNumber, memberOf and the schema's attributes under their LDAP names),
ou=groups (cn=<handle>, groupOfNames with member), ou=services — bind accounts for
applications, each with its own scope (people, groups, which attributes), the secret shown once;
outside its scope an application gets noSuchObject. A person binds with their own password under
the same lockout as everywhere; an account with a second factor cannot bind until the switch
allows it. Every write is refused. The listeners are switched on in the directory host's settings:
Servers → the directory row → Settings → the LDAP server field: off, ldaps (port 636),
starttls (port 389, for applications that speak only ldap:// with StartTLS; a bind before
StartTLS is refused) or both. The host applies it on its next poll and opens the ports itself.
The certificate is the one the host already holds for its public name, so the field needs a public
name, and nobody places PEM files or edits the env file by hand any more. Point applications at the
public name (ldaps://id.example.com). Turning it off closes 636 and 389 the same way — only the
ports the host opened for directory itself. The LDAP line on the Directory card shows what
actually listens and with which certificate, and says so when the setting is not applied yet.
The posixAccount and posixGroup switch in the LDAP server block adds the attributes Linux and
NAS clients use: on people uidNumber, gidNumber (equal to their own uid), homeDirectory
/home/<username>, loginShell /bin/bash; on groups gidNumber and memberUid. Numbers are
handed out once from a counter starting at 10000, and never change or get reused — files on a NAS
stay with the same person.
Webhooks and the audit export
Security → Synchronisation, the Webhooks card: an endpoint subscribes to lifecycle events (account
created / updated / deleted, group changed / deleted, consent granted
/ revoked, or *) and receives each as JSON signed with X-S2-Signature when a secret is set —
the same contract messaging's bots use. Three attempts; every delivery is an audit row; "Test"
sends a probe. Export audit downloads the whole security event log as JSON Lines for a SIEM;
filters on /control/audit/export: action= (a prefix such as login.), actor=, subject=,
from=/to= (RFC 3339), since=/until= (row numbers).
Pushing the audit log to a SIEM, and how long it is kept
The console → Logs & audit → Audit → SIEM push: the log can be sent as well as pulled.
kind: syslog-tls — RFC 5424 over TLS in RFC 5425 frames to address (host:port), optionally
with ca_pem (the collector's CA) and a client certificate client_cert_pem/client_key_pem;
syslog-tcp — the same without TLS; https — POST batches of JSON lines to url with the
bearer token. Every message carries the whole log row with its hash (facility authpriv,
severity by outcome, MSGID the action). A cursor remembers the last row taken: after a failure
the batch goes again after a back-off and nothing is skipped; the state line shows the cursor,
the last success or error and the queue, and "Push now" tries at once. Keep the log, days
(retain_days, 0 — everything, the default; otherwise at least 30): older rows are removed once a
minute, but never before the configured SIEM target has taken them. The last removed row becomes
the chain's anchor — verification starts from it, so the chain stays verifiable; a delete that
bypasses this is still refused by the database.
The sign-in page: branding, terms of use, logout of applications
The Branding card under the console's Security → Sign-in page: the organisation's name, a logo, one colour and
a tagline in both languages — taken by every page directory serves (sign-in, consent, device,
terms, the portal). Terms of use: a version above 0 is shown once at sign-in after the password
and the second factor — the summary on the page and a link to the full document; a decline is a
refused sign-in; a new version asks everybody again. An application with
options.backchannel_logout_uri receives a signed logout token when the person signs out on the
sign-in page or the help desk ends their sessions.
The directory in the console
The directory has no console of its own: its sections are parts of the coordinator's console, each where one looks for it.
| Where | What |
|---|---|
| Users → People, Guests, Groups, Bots | accounts, guests, groups (members, requests), bots |
| Users → Roles | "Who may open this console" — beside "Groups → roles" |
| Users → Address book | address lists, address-book policies, the organisation's books |
| Security → Sign-in requirements | second factor, credential policy, sessions, app passwords, access to SimpleTwo |
| Security → Sign-in page | sign-in domains, the company provider (OIDC, SAML), terms of use, branding, people's fields from the provider |
| Security → Applications | OIDC applications and signing keys |
| Security → Synchronisation | the LDAP source and its jobs, SimpleOne HRMS, SCIM (inbound and targets), the LDAP server, webhooks |
| Security → Access reviews | the policy and the open reviews |
| Security → Emergency access | the coordinator's break-glass account, vendor support access |
| Settings → Directory | the connection to the directory, moving the services, the attribute schema |
| Logs & audit → Sign-ins, Audit | the sign-in log, the directory's audit and the SIEM push |
| Overview | what needs attention, sign-ins over 14 days, the directory's setup |
The right comes from the account in the directory: the platform's owner/admin roles (or a group named in "Who may open this console") give identity-admin — everything; helpdesk/support give help-desk — People only, with its four actions (unlock, temporary password, reset 2FA, end sessions) and the person page's reads. Every act is audited under the person's name.
Since 27.09.2026:
- The console's Overview shows what needs attention in the directory: administrators and help desk without a second factor, accounts locked now, group join requests, overdue access reviews, SCIM targets, the SIEM push and webhooks failing (with the last error), an LDAP directory not answering or a company provider refusing, the audit chain and its storage alarm. A card opens the section where it is fixed (for people, with the filter applied). Below: sign-ins over 14 days (signed in and refused per day) and the setup checklist: public address, signing keys, an administrator with a second factor, a company provider or LDAP source, branding, SIEM, an access review policy.
- People is a table of name and username, e-mail, source, state (active, disabled, expired, locked, guest), second factor and last sign-in; filter chips (all, locked, disabled, guests, no second factor, no local credential, admins, admins without 2FA), search on username, name and e-mail, sort by a header, Export CSV of the filtered rows. A row opens the person page: the four help-desk actions in its header and the tabs Profile (attributes with the schema's labels, JSON collapsed), Credentials (password and its age, TOTP, recovery codes, passkeys and app passwords — each removable), Groups (remove, add), Sessions (end one or all), Sign-ins, Consents (third-party applications, revoke) and Activity (every audit row where the person acted or was acted on).
- Logs & audit → Sign-ins is the organisation's sign-in log over 30 days: time, person, how, application, address, result with the reason; filters by person, result, application, method and date. A row opens a panel with every field and the audit row's number and hash.
The help desk sees People only: the person page's read tabs (not Consents or Activity), the four actions and ending a single session; removing a passkey or an app password and revoking a consent are identity-admin's.
The person's portal — identity_url/me
Everybody signs in at identity_url/me through directory's own sign-in page (the client me,
registered by the role itself at start once DIR_PUBLIC_URL is set) and sees: profile (what
the directory knows about them, attributes through the schema with a label in their language and
a note of who sees each); security (change the password — with the current one;
enable the second factor — the secret and an otpauth:// link for the authenticator app, eight
recovery codes shown once; sessions on the sign-in page and in applications, "sign out
everywhere"); groups (mine, leave, join by id — open admits, approval sends a request to the
owners); address books (create, import vCard/CSV, export, delete); applications — the
launcher: everything they may sign in to, one click each. An application's launcher address is
set under Security → Applications (⋯ → "Launcher address"). The portal works when the
coordinator does not: it signs in through directory's provider.
Signing in through directory (OpenID Connect)
Directory is an OpenID Connect provider: /.well-known/openid-configuration on its public
address, the code flow with PKCE, EdDSA keys with rotation, a sign-in page in Russian and
English (the language comes from the browser or ui_locales). An application is registered
in the console under Security → Applications: public (a phone, a browser app — no secret, PKCE
required) or confidential (GitLab, vCenter — the secret is shown once and can be rotated);
redirect addresses are https only. Sign-in checks the password and the second factor held
by directory, ten misses lock for fifteen minutes; every decision is an audit row. The
SimpleTwo console and clients can name directory as their provider by its discovery address.
The company's providers behind directory. Where the organisation still has ADFS, Entra
or Keycloak, it is connected as an upstream: the console → Security → Sign-in page → "Company provider" — issuer,
client_id, secret, the list of mail domains. There may be several (the console →
Security → Sign-in page → "Company provider", the list): each has an id, a button label in Russian and English (label),
its own domains and provision. The sign-in page shows one button per provider; a provider's
domain signs in only through it — a password here is refused, and another provider cannot
vouch for that domain. After the provider's sign-in directory verifies the ID token and finds
the account by claim (upn, preferred_username, email, or the configured
username_claim). When there is no account: provision: off (the default) — "no account
here"; provision: create — directory creates an account of record (source: oidc, name and
e-mail from the standard claims, only in that provider's domains), the platform console
receives it through the change feed, and the sign-in is audited (oidc.upstream.provision).
The redirect address is one for every provider and is shown on the card. The earlier
single-provider setting is read as a list of one (id: default). The provider's own second
factor (mfa_via_upstream: true): when a sign-in made through this provider must step up —
an application with require_mfa, a SAML application asking for MFA — directory does not ask
for its own code or passkey but sends the person back to the provider with acr_values
(mfa_acr_values; by default https://refeds.org/profile/mfa and
http://schemas.microsoft.com/claims/multipleauthn, which ADFS understands; Entra reports MFA
in amr whatever it was asked), prompt=login, max_age=0 and login_hint. The answer
counts as two factors only when its ID token says so: amr with mfa (or a second kind of
factor next to the first sign-in's), ADFS's multipleauthn in amr, or an acr among
mfa_acr_values; it must also name the same person and not be an earlier sign-in
(auth_time). Otherwise the application gets access_denied and the session stays
one-factor. The sign-in log says company sign-in + the company's second factor
(oidc.upstream.stepup). For a provider over SAML the same is done with an AuthnRequest with
ForceAuthn="true" and a RequestedAuthnContext of the mfa_acr_values classes (the same two
by default), compared by mfa_comparison: minimum (the default) or exact. The answer counts
as two factors only when its AuthnContextClassRef (or ADFS's authnmethodsreferences
attribute) is REFEDS MFA, ADFS's multipleauthn or a class among mfa_acr_values, its NameID
is the one of the assertion the session was opened with (or the claims name the same person),
and its AuthnInstant is after the step-up began.
The company's provider over SAML (ADFS over SAML). A provider in the same list may be
kind: saml — directory is then its service provider (SP) rather than an OIDC client. The
easiest way in is the import: the console → Security → Sign-in page → "Company provider" → "Import IdP metadata (SAML)" —
paste the XML or give its address (for ADFS
https://<host>/FederationMetadata/2007-06/FederationMetadata.xml). A disabled draft is added
to the list with idp_entity_id, sso_url and sso_binding (HTTP-Redirect, or post when
the IdP offers HTTP-POST only), slo_url (the IdP's single logout over HTTP-Redirect) and the
signing certificates idp_certs; check it, set the domains, provision and the button label,
and save. Under the card appear the SP metadata address
https://<identity>/oauth/upstream/saml/<id>/metadata (also the entity ID), the ACS
…/oauth/upstream/saml/<id>/acs and the single logout address …/oauth/upstream/saml/<id>/slo
— ADFS creates its Relying Party Trust from that metadata address ("Import data about the
relying party published online"). sso_binding: post sends the AuthnRequest in a form that
submits itself (the signature inside the XML); redirect (the default) as a signed query. The
other fields:
nameid_format (empty asks for no format, and ADFS issues the one its rules give);
attribute_map — which SAML attribute a claim is read from (upn, email, name,
given_name, family_name, …); unlisted claims are read from ADFS's usual names
(http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn, …/emailaddress, …/givenname,
…/surname), Entra's and eduPerson's, and the NameID is the claim nameid (for example
username_claim: nameid). want_assertions_signed (on by default) requires the signature on
the assertion itself — which is how ADFS signs by default; off, a signature on the Response
alone is accepted too, but an unsigned answer never is. sign_requests (on by default) signs
the AuthnRequest with directory's RSA key. encrypt_assertions publishes an encryption
certificate in the metadata and accepts encrypted assertions (ADFS encrypts with AES-256-CBC,
which is accepted; AES-GCM too), and an encrypted NameID (EncryptedID) — in the assertion and
in the IdP's logout request; it is decrypted with the same keys and algorithms, and only after
the signature has verified. Without it an encrypted assertion or NameID is refused. The rest is as
for an OIDC provider: domains, finding or creating the account (source: saml), the session.
How the person signed in comes from the AuthnContextClassRef: ADFS's multipleauthn or REFEDS
MFA is a two-factor sign-in (mfa). Refusals are in the sign-in log with the reason "company
sign-in refused": a bad signature, another audience or ACS, an expired assertion (two minutes of
clock skew are allowed), a replay of an assertion already accepted, a sign-in started at the
IdP.
The address from SAML. SAML has no "the address is verified", so by default the asserted
address finds no account. trust_email: true is the administrator's statement that the IdP
only issues checked addresses: a sign-in whose login is not found is then linked to the one
account with that address, as an OIDC provider's email_verified does (oidc.upstream.link).
Do not turn it on for an IdP where people can change their own address.
A signing certificate rollover. With a metadata_url (https only) directory reads the IdP's
metadata again every six hours (spread a little; after a failure sooner, then backing off) and
at the "Refresh metadata now" button under the card. A certificate the IdP starts publishing
is added at once; one the metadata stops listing stays for a day and is then dropped — which is
how an ADFS rollover passes without an outage. A failed read (no answer, a document that does
not parse, another entityID) changes nothing, and the last certificate is never removed; the
last success, the error and the next refresh are shown under the card. Every change is an
oidc.upstream.metadata audit row with the SHA-256 fingerprints added and removed. Pasted XML
is never refreshed.
Single logout. With slo_url, a sign-out at directory (/oauth/logout, or a logout a SAML
application started) first logs the person out of directory's own applications, then sends
the browser to the IdP with a signed LogoutRequest (the NameID and SessionIndex of the
assertion the session was opened with); the IdP's answer (signed with its certificate, or
refused) brings the person back where the logout started. A logout started at the IdP (a
signed LogoutRequest to …/oauth/upstream/saml/<id>/slo, over Redirect or POST, at most five
minutes old, accepted once) ends the person's sessions that came through this IdP (only the
one with the SessionIndex, when it names one), logs them out of directory's applications and
answers the IdP with a signed LogoutResponse. The log: oidc.upstream.slo.
What the state line says
v0.2.0 · http://10.40.0.7:8086 · https://id.example.com — the role's version, the private
address the coordinator dials, and the public name if there is one. unreachable — the host
is enrolled but does not answer: read the role's log on the host
(journalctl -u simpletwo-identity); it is usually a DIR_DATA_DIR the service cannot write
to or, at the first start after the move to SQLite, a DIR_IMPORT_PG_DSN that pg_hba does not
admit.