# Identity and access (https://docs.vitrinka.ai/security/identity)



## Roles [#roles]

Four ranks per workspace, strictly ordered: **viewer** \< **member** \< **admin** \< **owner**. Above workspaces sits the organization, with owner, admin and member: organization owners and admins manage every team workspace of the organization, while a plain organization member reaches only the workspaces they have been added to, so one seat never opens the whole company. A personal workspace belongs to its owner alone. The router's floor for any write is member; workspace settings — the mirror binding and its sync, automation rules, the AI backend override, the recorder redaction policy, design settings — are admin and above, deliberately stricter than reading them. The ledger actor on engine writes is the verified identity behind the request, never a label the client sent.

## Credentials [#credentials]

* **Passwords** are stored as Argon2id hashes. Login attempts for an unknown address also perform a hash computation to reduce timing differences between existing and missing accounts.
* **Passkeys** are public credentials; an account with a passkey signs in with it. Adding and using one is described [below](#passkeys).
* **API tokens, connector tokens, session cookies and share-link secrets** are stored as SHA-256 hashes. Provider keys and share-link secrets that must be recoverable are sealed under the master key, bound to the record they belong to.
* **Publishable keys** — a bug-report key and a recorder token live inside apps and on testers' devices, outside the organization's machines. Each is confined to its own ingest routes and answered cross-origin only from origins it allows ([cross-origin access](/security/headers#cors)); a leaked recorder token can create sessions and acts only on the ones it started itself: it records into them and reads back only their status record — never their events, a board or a task — and it cannot read, close or write into a session any other credential started, a member's or another recorder's, even one linked by the same person. A recorder token is either linked from a device by approving a code — bound to the person, with a 30-day sliding expiry — or minted by an admin for one project.
* New and changed passwords are checked against known breach corpora through a k-anonymous range query: only the first five hex characters of the password's SHA-1 leave the server. The check is off under `AIR_GAP=1`.
* Every secret is minted from `crypto/rand`; sign-in, sign-up, verification, reset, passkey and connector-authorization attempts are rate limited, and denied attempts are audited.

## Passkeys [#passkeys]

A passkey is a WebAuthn credential — Touch ID or Face ID, Windows Hello, an Android or iPhone unlock, or a hardware security key — that signs in with one tap and no password. Vitrinka runs the ceremony itself against the app's host (`app.vitrinka.ai` on the SaaS, your `BASE_URL` when self-hosted), so nothing leaves the deployment; passkeys need an absolute `BASE_URL`, and a server that boots without one logs `passkeys disabled` and hides the sign-in button.

* **Add one** from **Settings → Security → passkeys → add a passkey**, or directly at `/auth/passkeys`: name the key ("MacBook Touch ID", "YubiKey"), press **Add a passkey**, and confirm on the device. Only a signed-in browser can register a key; every registration writes an `auth.passkey.add` audit row. Keys are resident (discoverable) credentials, so the sign-in page never asks for an email first.
* **Sign in** with **Sign in with a passkey** on `/auth/login`. The button appears only in browsers that support WebAuthn. A passkey sign-in mints the same session as a password sign-in — same audit row (`auth.login`, method `passkey`), same new-device notice, same landing rule — and it is subject to the same forced-SSO posture: an account whose organization forces SSO is refused at the finish, exactly like a password.
* **Remove one** from the same Settings island or the manage page; the removal is audited as `auth.passkey.remove`. The key stays on the device until you delete it there, but it can no longer sign in.
* **Browsers only.** The desktop app keeps your password, because its embedded web view cannot run the WebAuthn ceremony. Snap and the CLI sign in by approving a code in your browser, where your passkey works.
* **Break-glass interplay:** under a forced-SSO outage, the emergency door prefers the passkey — an account that has one must use it (see [Break-glass](#breakglass)).

## Single sign-on and MFA [#sso]

OIDC against your own directory — a hosted identity provider or one on your own network — configured in the product and covered in full in [Single sign-on](/operator/sso): forced SSO for an organization's members, for its claimed email domains (`VITRINKA_SSO_DOMAINS=on`), or for the whole deployment; group-to-role mapping reapplied on every login. Claiming a domain and, with claims on, turning forced SSO on take [deployment authority](#deployment).

MFA is enforced at the identity provider and **verified** here: with **Require MFA** on, the server checks the `amr` claim in `id_token` and refuses sign-in if the claim is missing or does not assert multi-factor authentication.

### An identity provider on your network [#sso-network]

Every call the SSO client makes — discovery, the key set, the token exchange and the settings page's health check — goes through one guarded client that checks each resolved address at connect time, redirects included. By default it reaches public addresses only. An identity provider on your own network, such as an internal Keycloak or ADFS and every air-gapped install's, is reachable when both of these hold:

* the operator lists its range in `VITRINKA_SSO_PRIVATE_NETWORKS` and restarts — keep the list to the provider's own address, a `/32` or `/128`;
* the connection is **deployment-managed**: its issuer, client ID and client secret were last saved by someone with deployment authority who is also an admin of that organization. An organization admin who changes any of those three makes it public-only again; groups, roles, MFA and default workspaces stay theirs to edit. Settings marks a managed connection beside its issuer, and the `org.sso` audit row of every save records the state.

Under `AIR_GAP=1` public addresses are taken away as well: a listed range inside the internal network (loopback, private, CGNAT, class E) is all the client reaches, an entry reaching past those ranges refuses the boot, and a connection no deployment authority saved reaches nothing ([Air-gapped deployment](/security/airgap#stays)). Link-local and known cloud-metadata addresses are never reachable, listed or not, and a list entry overlapping the link-local ranges refuses the boot. `VITRINKA_SSO_CA` adds a private certificate authority for this client alone, beside the system roots. What a managed connection vouches for is whoever runs its issuer: that party's DNS, discovery document and redirects decide where the client goes inside the listed ranges. Any save by deployment authority marks the connection, even one that only changes groups, so save only connections to an identity provider you run or trust. Revoking someone's deployment authority does not unmark what they saved: the connection stays managed until an organization admin saves its issuer, client ID or client secret, or deletes it.

## Break-glass [#breakglass]

`/auth/login?local=1` arms a five-minute emergency grant when the identity provider is down. `VITRINKA_BREAK_GLASS` decides who may complete it — `owners` of the forcing organization by default, `all`, or `off` — decided after the credential is proven so the sign-in page never reveals membership. Passkey-first: an account with a passkey must use it. Every use, and every denial, writes an `auth.break_glass` audit row.

## Deployment authority [#deployment]

Every account owns an organization from its first sign-in, so administering an organization never carries power over the deployment. The settings that act on everyone who signs in belong to deployment authority:

* the deployment mailer, which sends every password reset and sign-in code ([the deployment mailer](/operator/mail));
* claiming an SSO email domain, and turning forced SSO on while domain claims are on;
* making an SSO connection deployment-managed ([above](#sso-network));
* letting an issue tracker, a workspace AI backend or an organization's AI endpoint reach a private, loopback or internal address ([Outbound targets](/operator/connectors#outbound)).

Deployment authority is held by **platform admins** and, when `VITRINKA_OPS_ORG` names the organization that runs the install, by that organization's admins too; unset, platform admins alone. It grants no data access. A change by anyone else is refused with a `403` that names who can make it. An organization admin keeps the rest of their own SSO connection, may release their own domain claims and may turn forced SSO off. A platform admin acts through an organization they administer — there is no cross-organization door — so on a multi-organization install they are added to that organization first.

Platform admin is granted with the server binary, inside its container; the change applies on the account's next request and is audited as `platform.admin.grant`:

```bash
docker compose exec vitrinka /vitrinka platform-admin grant you@example.com
```

`VITRINKA_OPS_ORG` must name an organization that already exists; a sign-up is never given that slug, so an organization that does not exist yet cannot be squatted into authority ([configuration reference](/reference/env#core)). After an upgrade, domain claims and forced connections saved under the old rules are kept as they were — review them — and a connection to an identity provider on your network stays public-only until deployment authority saves it once. The same once-only save brings back a tracker, AI backend or AI endpoint on your network ([after upgrading](/operator/connectors#outbound)).

## Sessions [#sessions]

`VITRINKA_SESSION_TTL` (720h by default) is the absolute lifetime; `VITRINKA_SESSION_IDLE` (off by default, at least `5m` when set, shorter than the lifetime) adds a sliding idle timeout. Both are enforced server-side. The enterprise bundle's compose sets 12h and 30m.

## Agents and connected apps [#agents]

AI assistants that connect as an OAuth client use OAuth 2.1 with mandatory PKCE (S256), public clients only — there is no client secret to leak. Metadata is at `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`; clients register with a client-ID metadata document or, as a fallback, at `POST /oauth/register` — the only way under `AIR_GAP=1`, which fetches no metadata document; the resource is `/mcp`. Grants are scoped read or read-write and bound to one app and one user. A connection reaches only the workspaces its user ticked on consent or later in Settings — whole organizations (including workspaces created there later) or single workspaces, across organizations if they choose, each organization audited on its own; the Personal workspace only when ticked explicitly. Every call is checked first against the user's live membership of the workspace it addresses, then against the connection's reach, and acts with member authority — so a viewer cannot connect an app; a workspace outside the reach is refused with `403 workspace_not_connected`, never a sign-in prompt. A workspace admin can remove a connection from their workspace; the user cannot add it back. Losing membership drops the workspace from every connection at once, and a connection left with nothing is revoked. A connection cannot manage the account: sessions, personal access tokens, sign-out-everywhere and an organization's email settings need the browser or a CLI token. Clients retire a connection at `POST /oauth/revoke` (RFC 7009). Access tokens live one hour, refresh tokens rotate on every use, and a rotated token presented again more than 30 seconds after its rotation revokes the connection — inside that window, where sessions sharing one sign-in race a refresh, it answers a fresh pair and is audited. Registration, authorization and token endpoints are rate limited per address.

The CLI authenticates with a personal bearer token, re-scoped to each workspace it addresses and capped at member. The CLI stores credentials in the operating system keychain by default. On machines without a usable keychain, `vitrinka auth login --insecure-storage` permits an explicit plaintext fallback in its local config file ([MCP clients](/dev/mcp/clients)).

## Share links [#share]

A share link is a capability for one board: whoever holds it opens that whole board without an account, read-only or with annotation rights, until it expires or is revoked — [sharing with people outside](/use/sharing).

* **The secret** is minted from `crypto/rand`, stored as a SHA-256 hash for lookup and sealed under the master key so the share popover can copy it again. An optional passphrase is kept as an Argon2id hash. A link expires after 24 hours, 7 days, 30 days (the default) or never, and revoking it ends it at once. Creating and revoking a link are audited as `share.create` and `share.revoke`.
* **Its reach** — workspace members, admins and owners mint links, viewers cannot, and a personal board can never be shared. A link opens its one board and nothing else in the workspace; guests are neither members nor seats. On a dedicated [share origin](/security/headers#share) no session or token is accepted at all.
* **Guests** give a name at their first write, audited as `share.guest`. The name attributes their work and authorizes nothing: which guest owns an annotation is decided by the server from that guest's own cookie, kept per link for 30 days.
* **Staged until released** — nothing a guest writes reaches an agent's queue until a member releases it, item by item. A member may trust a guest with a verified email: that guest's reminders and issue requests then run without a release, at most 20 a day per link, the rest staying proposals. Revoking trust on a link returns every guest on it to proposals at once.
