Личный кабинет ALTLinux ID. Основное место разработки
  • JavaScript 27.4%
  • TypeScript 25.5%
  • Rust 22.2%
  • Svelte 15.9%
  • Shell 6.7%
  • Other 2.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alexey Shabalin cb6c592702
All checks were successful
CI / server (pull_request) Successful in 59s
CI / web (pull_request) Successful in 3m45s
CI / ci (pull_request) Successful in 3s
Run the web and server checks as two parallel jobs
Each job installs only its own toolchain: the server job no longer
waits for npm, the unit tests, axe and chromium, and the web job no
longer waits for the Rust build.
2026-09-26 11:18:06 +03:00
.forgejo Run the web and server checks as two parallel jobs 2026-09-26 11:18:06 +03:00
.vscode feat: ALT Linux cabinet on svelte + PatternFly 6, live kanidm 2026-08-16 03:00:13 +05:00
deploy Check that the administrators are what the requirement says 2026-09-23 16:30:38 +03:00
docs Fill the release tab from a signed tag 2026-09-26 08:51:35 +03:00
e2e Suggest a login while somebody is typing it 2026-09-22 17:49:09 +03:00
public Give the external providers their own marks 2026-08-21 14:55:36 +03:00
server Find a person by their address 2026-09-22 16:13:34 +03:00
src Suggest a login while somebody is typing it 2026-09-22 17:49:09 +03:00
test Fill the release tab from a signed tag 2026-09-26 08:51:35 +03:00
.env.example Call the service by one name: alt-ego 2026-08-26 17:47:41 +03:00
.gitignore Do not carry an empty .mcp.json into the repository 2026-08-27 19:31:50 +03:00
admin.html Serve the admin console as a document of its own 2026-09-09 01:07:26 +03:00
AGENTS.md Say when a working copy is done with 2026-09-04 19:34:27 +03:00
AUTHORS legal: AGPL-3.0-or-later license, AUTHORS, SPDX headers 2026-08-16 10:48:44 +05:00
Cargo.lock Release 0.3.0 2026-09-23 17:10:02 +03:00
Cargo.toml Release 0.3.0 2026-09-23 17:10:02 +03:00
eslint.config.js Attach event handlers with on() from svelte/events 2026-09-10 17:16:24 +03:00
index.html Call the service one name: ALT Linux ID 2026-08-25 12:58:31 +03:00
LICENSE legal: AGPL-3.0-or-later license, AUTHORS, SPDX headers 2026-08-16 10:48:44 +05:00
NEWS.md Write down what 0.3.0 changes 2026-09-23 17:09:49 +03:00
package-lock.json Stop declaring a parser the plugin already brings 2026-08-26 13:16:12 +03:00
package.json Let a person delete their own account 2026-09-15 10:30:24 +03:00
README.md Answer the review: controls that lied, and a link that outlived its errand 2026-09-11 23:26:19 +03:00
svelte.config.js feat: ALT Linux cabinet on svelte + PatternFly 6, live kanidm 2026-08-16 03:00:13 +05:00
TODO.md Say in the README and the TODO what the console now is 2026-09-11 03:22:27 +03:00
tsconfig.app.json Give the WebAuthn wire format a module and a test 2026-08-18 21:19:32 +03:00
tsconfig.json feat: ALT Linux cabinet on svelte + PatternFly 6, live kanidm 2026-08-16 03:00:13 +05:00
tsconfig.node.json feat: ALT Linux cabinet on svelte + PatternFly 6, live kanidm 2026-08-16 03:00:13 +05:00
tsconfig.test.json Give the WebAuthn wire format a module and a test 2026-08-18 21:19:32 +03:00
vite.config.ts Draw the console: people, one person, groups, one group 2026-09-11 03:21:56 +03:00

alt-ego

Developed at ALTLinux/alt-ego on altlinux.space: master lives there, pull requests go there, and so does the issue tracker.

The ALT Linux Team account cabinet (idm.altlinux.space): a Svelte 5 + TypeScript + Vite SPA on PatternFly 6 talking to the live kanidm /v1 API, and alt-ego-server (server/, Rust + axum) for the few things a browser must not be trusted with. The server serves the built SPA as well, so a deployment is two processes — kanidm and the cabinet — behind one reverse proxy on one origin.

What a person does to their own account, the browser does itself, with their own kanidm session. What goes through the server: creating a person at registration (a privileged token the browser may not hold), the "forgot my password" form, proving that an address is theirs (an email has to be sent and a single-use token kept secret), and adding an ssh or gpg key (the key is parsed before it is stored, and a check the browser makes is a check it can skip). The server keeps no state of its own at all — no database, nothing on disk between requests: kanidm holds everything, credentials included. What the server did in kanidm on somebody's behalf goes to the process log rather than a table of its own: one event per operation under the target audit, carrying action, account, detail and ip as named fields — journald's own fields under systemd, so journalctl ACTION=ssh_key_added selects (see "The trail"). No email addresses are written — the account uuid says whose record it was.

Svelte was chosen over Vue in a bake-off (see docs/COMPARISON.md): lighter bundle, fewer dependencies, less tooling friction, tie on code volume. The Vue implementation is archived in alt-ego-vue.

Screens

Login, Register, Profile (+ emails), Password & sign-in (password / TOTP / OAuth / backup codes), SSH keys, GPG keys, Sessions, Notifications, and the screens reached from a link in an email: reset, recover, enrol, and the OAuth2 consent screen. EN (source) + RU localisation, light/dark theme, responsive (sidebar collapses to a strip below 860 px).

Notifications is marked "In development" in the interface and here: the four preferences it records are written to the account, and nothing that sends mail reads them yet.

The administration console

A second document at /admin, with its own bundle, so the page everybody loads does not carry screens almost nobody may open: a list of people and one person's card, a list of groups and one group's card. It finds people by login, display name or legal name — not by address, which kanidm's SCIM filter refuses (ALTLinux/alt-ego#82) — changes a record in one PATCH, creates people and groups, and adds to or removes from a group's membership.

It has no rights of its own. Every read and every write goes to kanidm as the administrator who is signed in, so kanidm's access controls decide all of it, and which controls a card offers comes from what the server said about that record (extAccessCheck). Two facts are put on screen because they explain refusals that otherwise look arbitrary: which group manages a group, and whether kanidm protects it as high privilege.

It shows what the administrator's own rights allow and says so where they do not. An attribute that may not be read is absent from the answer rather than refused, so the card writes different words for "nothing here" and "your account may not see this" — a distinction it can only make because the read asks the server what it may see. Keys, provider links, direct memberships and sessions are readable by an identity administrator only because the fork adds a profile for it (idm_acp_alt_console_person_*); upstream keeps all five for the account itself. What each profile grants is in docs/privileges.md.

Who is shown the console is decided by the server before the document is served — [console] groups in the config, defaulting to kanidm's own administrative groups (server/src/console.rs). An account outside them is sent to the cabinet instead of being handed a full frame over empty lists. That is about not showing a console to somebody who cannot use it; it is not an access control, and the cabinet draws its link to the console by the same answer (console in GET /api/v1/me).

Run

The app is same-origin with two servers, and in dev Vite stands in for the reverse proxy that makes them one (vite.config.ts): /v1, /oauth2 and /ui go to kanidm (KANIDM_UPSTREAM), rewriting the cookie domain so the session works on localhost, and /api goes to this repo's own server in server/ (CABINET_UPSTREAM, default http://127.0.0.1:8080). Anything not proxied falls through to the SPA, which answers index.html with a 200 — so a call under a new prefix needs an entry there, and devproxy.test.ts fails if it does not have one.

npm install
cp .env.example .env.local   # and put the real kanidm address in it
npm run dev                  # http://localhost:5173/

npm run build writes the SPA to dist/. In production that directory is served by alt-ego-server itself (web_dist in its config), same-origin with kanidm behind the reverse proxy — there is no separate web server.

The server

server/ is the only member of the Cargo workspace:

cargo build --release -p alt-ego-server   # → target/release/alt-ego-server
alt-ego-server [CONFIG]    run the server

The config path comes from the argument, else $ALT_EGO_CONFIG, else /etc/alt-ego/config.toml; deploy/server.example.toml documents every key. Nothing is created on start: there is no database and no state directory.

The server answers /api/v1/… and /healthz; everything else is served from web_dist, with a fallback to index.html so the SPA's own routes resolve. Leave web_dist unset and no static file is served at all — the API answers and every other address is a 404.

Which of those calls the browser makes for itself and which have to come through here, what the service account may do in kanidm and what it is deliberately refused, and which of the two extractors a new endpoint should take — that is docs/privileges.md, written for somebody adding an operation.

The trail, and how to ask it anything

What the server did in kanidm on somebody's behalf is one log record per operation — action, account, detail, ip, under the target audit (server/src/audit.rs). Under systemd those are journald's own fields, upcased: ACTION, ACCOUNT, DETAIL, IP, alongside TARGET=audit for the trail as a whole. So the journal is the query interface, and the questions worth asking are one command each:

# everything the service did for one account
journalctl -u alt-ego ACCOUNT=1cfeee42-2bad-45c9-be1e-a44c72e0b6f0

# everything that did not finish. The ones worth knowing by name:
# register_mail_failed (the account exists, the password-setup mail did not go,
# and nobody told the person); recovery_mail_failed, whose DETAIL says which
# repair it needs — no-route (this is not the fork), refused (kanidm has the
# route and will not use it), empty (kanidm is unwell); and
# recovery_rate_limited — that last one is the answer to "I asked for a reset
# and nothing came", because the per-address limit is deliberately invisible at
# the form and so leaves no other trace. Recovery records carry no ACCOUNT: the
# cabinet hands kanidm an address and never learns whose it was.
journalctl -u alt-ego TARGET=audit PRIORITY=4

# one kind of operation over the last day
journalctl -u alt-ego --since -1d ACTION=person_registered

# the whole trail, as fields rather than as lines
journalctl -u alt-ego TARGET=audit -o json-pretty

# why kanidm refused something. The reason is a field, not part of the message,
# so the default output does not show it: "kanidm rejected the request" alone
# says nothing, while DETAIL carries kanidm's own answer, e.g.
# {"schemaviolation":{"invalidattributesyntax":"alt_gpg_key"}}.
journalctl -u alt-ego -o verbose

The same holds for every record, not only the trail: named fields reach journald as fields. Reading the log with the default output and concluding that a message carries no detail is a mistake worth naming once — the detail is there, one -o verbose away.

The level travels as journald's priority: an ordinary operation is PRIORITY=5 (notice), the one that did not finish is 4 (warning) — -p warning finds it too, and takes anything louder with it.

Where journald is not listening — a container, cargo run in a terminal — the same records go to stderr as text with their fields still named. The choice is automatic: systemd sets JOURNAL_STREAM when the process's own stderr is the journal, and that is the signal. ALT_EGO_LOG=journald|stderr|auto decides instead of it; anything else in that variable is ignored, with a line in the log to say so, because a mistyped log setting must not be why a service is down. Neither is an unreachable journald: the log falls back to stderr and says why. RUST_LOG=audit=info still selects the trail and nothing else, whichever sink it goes to.

The language of what it sends

Everything mailed from here is in Russian: the five notices and confirmations the cabinet writes itself (server/src/api/me.rs, server/src/api/profile.rs), the password reset in deploy/mail-sender.py, and the credential-reset mail in kanidm-mail-sender — the fork's own sender, built from tools/mail_sender in the kanidm fork, the one that actually runs on the stand. Both of the last two were in English until recently. deploy/mail-sender.py's reset mail was one message out of seven, so somebody who changed their address and then reset their password could receive the two halves of one account in two languages. kanidm-mail-sender's was the more common miss: it is the template kanidm queues both at registration (_update_intent_send) and for account recovery, so it was the one mail a person was all but guaranteed to receive in English however they had asked for the account in Russian (ALTLinux/alt-ego#13).

One language rather than the reader's, because there is nowhere to keep the reader's. The interface has two and remembers the choice in the browser (altlk-lang in localStorage, mirrored into the ui-locale cookie so kanidm's own pages agree); kanidm's account entry has no attribute to keep it in, and this service keeps nothing about a person that kanidm does not. Some of these messages are also addressed away from the person at the keyboard — a change of the primary address is announced to the address it used to be — so even a language taken from the request that caused the mail would sometimes be the wrong one for its recipient. Guessing per message would mean one account's mail arriving in two languages, which is worse than one language somebody may have to translate. Russian is that language because it is the one the great majority of this deployment's correspondence, its documentation and its lists are already in.

Giving each account a language of its own is the real fix, and it is a change to what kanidm stores, not to these seven strings.

None of this is in this repository: kanidm-mail-sender lives in the kanidm fork, not here, so its Russian text can't be found by grepping alt-ego. See tools/mail_sender/src/main.rs there (to_email_content) instead of looking for it under server/ or deploy/ in this repository.

Secrets

Nothing secret is written in the config file. Each secret is either a file the config points at — service_token = { file = "…" }, which is how systemd's LoadCredential= hands it over under /run/credentials/alt-ego.service/ — or an environment variable. The environment is looked at first, and a non-empty value there wins; otherwise the file is read and its first line used, trimmed.

variable config key unset
ALT_EGO_SERVICE_TOKEN kanidm.service_token the server refuses to start: it is the token everything the browser may not do is done with
ALT_EGO_REGISTRAR_TOKEN kanidm.registrar_token registration uses the service token, which is wider than it needs
ALT_EGO_SMTP_PASSWORD mail.smtp_password the relay is used without logging in — right for a relay on our own network, and what the stand does
ALT_EGO_CAPTCHA_SECRET captcha.secret pow and none need no secret; smartcaptcha and recaptcha refuse to start without one

That row says "wider than it needs", and it stays that way: a separate account whose rights stop at creating a person cannot be built, because the same token also asks kanidm to mail the link for setting the password, and that takes idm_acp_alt_portal_credential_reset, whose target is every ordinary person. Without that profile the mail is never sent; with it the account can reset any ordinary person's credentials. deploy/server.example.toml lists the three refusals a create-only token runs into.

Every "unset" in that table means unset — neither the variable nor a file. A file that is named and holds no secret stops the server, whichever secret it belongs to: a path in the config is a statement that the file is there and that the secret is in it. So a LoadCredential= that did not arrive, a wrong owner, a typo in the path — and equally a file of zero bytes, or of nothing but whitespace, which is what a write that ran out of disk or a truncated source leaves behind — is a malfunction and not a decision. Reading any of them as "no secret configured" is how the registrar token would quietly become the wider service token, how the relay password would become no login at all, and how an unreadable captcha secret would be reported as a captcha that was never set up.

The environment is the one place where blank does mean unset: Environment= in a unit and an unexpanded ${VAR} in a shell are how an environment spells "nothing here", so a variable that is empty or all whitespace simply lets the file take its turn. Its value is trimmed before use, so such a variable can never become a token made of spaces.

The values themselves come from kanidm — deploy/setup-kanidm.sh prints the service-account api-token generate command that mints them.

One key is deliberately in neither the table nor the config: the one the proof-of-work captcha signs its challenges with. It is generated at startup and never leaves memory, because what makes a solution single-use — the record of the solutions already spent — lives in memory too, and a key that outlived a restart would let a solution spent before it be presented again after. The cost of tying them together is that a restart refuses the challenges outstanding at that moment; they live five minutes and are solved in seconds, and the page asks for a fresh one on every attempt (server/src/captcha.rs).

Deploy — one name, two servers behind it

The cabinet must be same-origin with kanidm: the session is an HttpOnly cookie kanidm scopes to its own domain, and kanidm sends no CORS headers. So one public name, and a reverse proxy that splits its paths between two processes — kanidm and alt-ego-server, which serves the built SPA itself. The split is also how the cabinet replaces kanidm's own pages: a few of kanidm's /ui routes must keep reaching it, and the rest must not.

Build the SPA first (npm run build → dist/) and point the server's web_dist at it.

Who answers what:

path answered by why
/v1, /scim, /oauth2, /.well-known, /status, /robots.txt kanidm the API the SPA speaks, and the OIDC endpoints downstream clients (Forgejo, Matrix) speak
/ui/login/… kanidm leaving for an external provider and coming back (/ui/login/oauth2_landing): client secret and PKCE state the browser may not hold
/ui/oauth2/link, /ui/oauth2/unlink, /ui/oauth2/consent, /ui/oauth2/resume kanidm same, and they live under a prefix the cabinet takes — so they have to be named ahead of it
/ui/logout kanidm the end_session_endpoint from the OIDC discovery document: where Forgejo sends somebody who pressed "sign out" there — by GET or by POST, and only kanidm can verify an id_token_hint or destroy the session
/ui/api, /pkg kanidm the provider and link lists the SPA reads, and kanidm's own static files
/ui/oauth2 — that exact path cabinet the authorization_endpoint kanidm publishes in OIDC discovery: the consent screen, drawn by the cabinet now
/ui/logout/confirm — that exact path cabinet "sign out?" — RP-Initiated Logout 1.0 §2's question, which kanidm redirects to once it has checked the request. Everything with a consequence stays at /ui/logout above
/ui/reset, /ui/recover, /ui/revoke, /ui/enrol (exact) cabinet the link from an email, "forgot my password", "it wasn't me" from that same email, enrolling another device
the rest of /ui redirect to / kanidm's pages the cabinet has replaced: a bookmark should arrive somewhere rather than almost work
/api/v1/… cabinet registration, recovery, addresses, keys — see "The server" above
everything else cabinet the SPA, from web_dist, with index.html as the fallback

/ui/oauth2, /ui/enrol and /ui/logout/confirm are matched exactly, and that is the whole point of them. Their neighbours under the same prefix are kanidm's and must keep reaching it, and a prefix rule would also answer for /ui/enrolabc — the cabinet serves index.html for anything it does not recognise, so it would draw somebody's profile at an address that means nothing. Nothing lives underneath any of the three.

/ui/logout/confirm is the one where getting it wrong is expensive. /ui/logout itself is the end_session_endpoint in kanidm's OIDC discovery document: Forgejo sends people there, it is allowed to send them by POST — which a static SPA answers with a 405 — and the session cookie is HttpOnly and kanidm's to destroy. A prefix rule for the confirmation screen would swallow the endpoint and break signing out everywhere at once, silently: the person would land on the cabinet, see nothing about a logout, and still be signed in. Path(), not PathPrefix(), and src/lib/logout.test.ts fails if the app or the dev proxy starts claiming the endpoint.

One instance — an invariant nothing enforces

alt-ego-server is meant to run as exactly one process behind the proxy. There is no lock, no health check and no error that says otherwise: put a second one behind a load balancer and what breaks, breaks quietly.

The server keeps no database, and the little it holds between requests it holds in the memory of its own process:

  • the key the proof-of-work captcha signs its challenges with, and the record of the solutions already spent (server/src/captcha.rs);
  • the rate-limit counters — per IP, per target address, and the global fuse (server/src/ratelimit.rs).

With two instances, A signs a challenge and the balancer hands the solution to B, which does not have A's key. The signature does not verify and the form answers exactly as it would to a wrong captcha. Registration and recovery are the two forms that carry one, so with N instances roughly (N−1)/N of the attempts at either fail: no mail is sent, and the person is told to try again by a page that will fail again. Nothing logs a deployment error, because from each process's side nothing is wrong — it was shown a challenge it never issued. The record of spent solutions splits the same way, so one solution can be presented once per instance; and every counter in ratelimit.rs divides by N, which turns the "no more than a few links an hour to one address" ceiling on recovery mail into N times that.

The signing key is deliberately not one more deployed secret, and that choice is what fixes the instance count at one. It shares a lifetime with the spent-solution cache on purpose: a key that outlived the cache would let a solution spent before a restart be presented again after it, and single-use would hold only between restarts. See "Secrets" above.

If there ever has to be more than one instance, the fix is not a flag:

  • the signing key becomes a real secret, deployed to every instance, and the record of spent solutions moves to a store they all share and that expires entries the same way. One without the other only moves the hole: a shared key with per-process caches makes every solution usable once per instance.
  • the rate-limit counters move to that same shared store, or the numbers in the config are divided by the instance count and read as per-process.
  • sticky routing by client address at the proxy is the cheap version and covers both — for as long as the stickiness holds. It stops covering them the moment an instance is added, removed or restarted, which is precisely when a rebalance sends people to a process that has neither their challenge nor their counters.

Restarting the one instance is an ordinary operation and needs none of this: the challenges outstanding at that second are refused, they live five minutes, they are solved in seconds, and the page asks for a fresh one on every attempt.

Traefik — what is actually deployed

The stand and production run Traefik (file provider), with the priorities doing the ordering. Rules, in the order they are checked:

# 200 — cross-site POST trap. Some kanidm routes accept the cookie session, and
# a browser attaches that cookie to a request another site sent. Blind-firing
# /oauth2/authorise in a victim's browser would reach an authorisation code.
# Only `cross-site` is caught: `same-site` is left alone on purpose, because
# RP-Initiated Logout lets Forgejo on a neighbouring subdomain POST to us —
# which is also why SameSite=Lax alone is not enough here. Non-browsers send no
# Sec-Fetch-Site at all and are untouched.
idm-block-cross-site-post:  # -> middleware `deny` (an ipAllowList nobody matches)
  rule: "Host(`idm.altlinux.space`) && Method(`POST`) && Header(`Sec-Fetch-Site`, `cross-site`)
         && (PathPrefix(`/oauth2`) || PathPrefix(`/v1`) || PathPrefix(`/api/v1`) || PathPrefix(`/ui`))"

# 150 — rate limit on the ways in. Needed here and not only in the applications:
# kanidm sees every client as one address until [http_client_address_info] names
# the proxy, so its own brute-force defence counts everybody as one person.
idm-auth-ratelimit-kanidm:   rule: "… && (Path(`/v1/auth`) || Path(`/v1/reauth`))"                        # -> kanidm
idm-auth-ratelimit-cabinet:  rule: "… && (Path(`/api/v1/register`) || PathPrefix(`/api/v1/recover`))"     # -> cabinet

# 130 — "sign out?", the only thing the cabinet draws under /ui/logout. Path(),
# and that exactness is all that keeps this rule from eating the one below it.
idm-cabinet-logout-confirm:
  rule: "… && (Path(`/ui/logout/confirm`) || Path(`/ui/logout/confirm/`))"

# 120 — the /ui routes that stay kanidm's.
idm-kanidm-ui-keep:
  rule: "… && (PathPrefix(`/ui/login/`) || PathPrefix(`/ui/oauth2/link`) || PathPrefix(`/ui/oauth2/unlink`)
         || PathPrefix(`/ui/oauth2/consent`) || PathPrefix(`/ui/oauth2/resume`) || PathPrefix(`/ui/logout`)
         || PathPrefix(`/ui/api`) || PathPrefix(`/pkg`))"

# 110 — the screens the cabinet draws. Path(), not PathPrefix(), for /ui/oauth2
# and /ui/enrol.
idm-cabinet-screens:
  rule: "… && (Path(`/ui/oauth2`) || Path(`/ui/oauth2/`) || PathPrefix(`/ui/reset`)
         || PathPrefix(`/ui/recover`) || PathPrefix(`/ui/revoke`) || Path(`/ui/enrol`)
         || Path(`/ui/enrol/`))"

# 100 — kanidm's API. Listed explicitly rather than as "everything else",
# because downstream clients come here too.
idm-kanidm-api:
  rule: "… && (PathPrefix(`/v1`) || PathPrefix(`/scim`) || PathPrefix(`/oauth2`)
         || PathPrefix(`/.well-known`) || Path(`/status`) || Path(`/robots.txt`))"

#  80 — the rest of /ui: redirect to the cabinet's front page (middleware `to-cabinet`).
#  70 — PathPrefix(`/api/v1`): the cabinet's own API.
#  10 — Host() alone: the cabinet.

The cabinet's routers also carry a cabinet-security middleware with the security headers (CSP default-src 'self' with 'unsafe-inline' in style-src only, frame-ancestors 'none', HSTS, no-referrer). It is set for the cabinet and not for kanidm: kanidm sets its own, with hashes of its own scripts in script-src, and a second set from Traefik would overwrite them and break its pages.

Two things the services need: passHostHeader: true (kanidm compares Host against its configured origin and refuses a mismatch) and, towards kanidm, insecureSkipVerify — kanidm always speaks TLS, with a self-signed certificate on an internal name. The whole file, with TLS and the service definitions, is kept with the stand configuration as traefik/idm.yml, and is deliberately not copied into this repository: a second copy of a live configuration drifts from the first, and the copy that is wrong is always the one somebody reads. What is here instead is the excerpt above — the rules, in the order they are checked, with the reasoning that does not survive being read off a YAML file.

One of those rules is a security control rather than routing, and it is the one to carry across to any other proxy: idm-block-cross-site-post at priority 200, quoted in full above. It belongs at the proxy because that is the only place it can live — what it stops is aimed at kanidm's routes, not at the cabinet's, and kanidm accepts a cookie session on endpoints that take form-encoded POST by RFC 6749. The cabinet's own API does not depend on it: its mutations are JSON, which a cross-site form cannot send without a preflight. Deploy behind anything other than that file and this rule has to be reproduced by hand — the next section shows what that looks like, and does not do it for you.

nginx — the same split, for a deployment without Traefik

This example carries no cross-site POST trap of its own until you add the first block below. The Traefik rule idm-block-cross-site-post is a security control and nothing about the path split reproduces it: copy only the routing and /oauth2/authorise stays reachable from a form on another site, in a browser that still holds a kanidm session — which is the whole reason that rule exists. It is the one piece of the Traefik file that has to be carried across by hand, so it is written out here first.

# The cross-site POST trap — the equivalent of Traefik's
# `idm-block-cross-site-post`. Wider than that rule on purpose: it covers the
# whole host rather than /oauth2, /v1, /api/v1 and /ui, which costs nothing here
# — nothing else on this name has any business taking a POST from another site.
# Only `cross-site` is caught: `same-site` is left alone, because RP-Initiated
# Logout lets Forgejo on a neighbouring subdomain POST to us, which is also why
# SameSite=Lax alone is not enough. A non-browser sends no Sec-Fetch-Site at all
# and is untouched. At `http` level, since `map` cannot live inside `server`.
map "$request_method:$http_sec_fetch_site" $cross_site_post {
    default           0;
    "POST:cross-site" 1;
}

server {
    # Before anything is routed. `if` with `return` is one of the two uses of
    # `if` that behave, and it is checked once per request at server level.
    if ($cross_site_post) { return 403; }

    listen 443 ssl;
    server_name idm.altlinux.space;
    ssl_certificate     /etc/ssl/…/fullchain.pem;
    ssl_certificate_key /etc/ssl/…/privkey.pem;

    # kanidm compares Host with its own origin, and both servers resolve the
    # client address from X-Forwarded-For (kanidm's [http_client_address_info],
    # the cabinet's ratelimit.trusted_proxies — name this proxy in both, or
    # every caller counts as one).
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    # kanidm always speaks TLS and holds a self-signed certificate on an
    # internal name; the hop to it is https, unverified.
    proxy_ssl_verify off;

    # kanidm's API, and the OIDC endpoints downstream clients use.
    location /v1/          { proxy_pass https://127.0.0.1:8443; }
    location /scim/        { proxy_pass https://127.0.0.1:8443; }
    location /oauth2/      { proxy_pass https://127.0.0.1:8443; }
    location /.well-known/ { proxy_pass https://127.0.0.1:8443; }
    location = /status     { proxy_pass https://127.0.0.1:8443; }
    location = /robots.txt { proxy_pass https://127.0.0.1:8443; }

    # kanidm's own screens that must keep answering.
    location /ui/login/    { proxy_pass https://127.0.0.1:8443; }   # to a provider and back
    location /ui/oauth2/   { proxy_pass https://127.0.0.1:8443; }   # link, unlink, consent, resume
    location /ui/logout    { proxy_pass https://127.0.0.1:8443; }   # end_session_endpoint
    location /ui/api/      { proxy_pass https://127.0.0.1:8443; }   # provider and link lists
    location /pkg/         { proxy_pass https://127.0.0.1:8443; }

    # The screens the cabinet draws. Exact matches where they must be exact —
    # /ui/logout/confirm above all, since the prefix above it is the
    # end_session_endpoint. `location =` outranks the prefix match, so this
    # takes the question and leaves the endpoint alone.
    location = /ui/oauth2  { proxy_pass http://127.0.0.1:8080; }
    location = /ui/oauth2/ { proxy_pass http://127.0.0.1:8080; }
    location = /ui/enrol   { proxy_pass http://127.0.0.1:8080; }
    location = /ui/enrol/  { proxy_pass http://127.0.0.1:8080; }
    location = /ui/logout/confirm  { proxy_pass http://127.0.0.1:8080; }
    location = /ui/logout/confirm/ { proxy_pass http://127.0.0.1:8080; }
    location /ui/reset     { proxy_pass http://127.0.0.1:8080; }
    location /ui/recover   { proxy_pass http://127.0.0.1:8080; }
    location /ui/revoke    { proxy_pass http://127.0.0.1:8080; }   # "it wasn't me" from the email

    # Everything else under /ui was kanidm's and is gone: send it to the front
    # page rather than let it almost work — /ui/login among the rest.
    location = /ui         { return 302 /; }
    location /ui/          { return 302 /; }

    # The cabinet: its API, and the SPA it serves from web_dist.
    location /api/v1/      { proxy_pass http://127.0.0.1:8080; }
    location /             { proxy_pass http://127.0.0.1:8080; }
}

What bites if it is missed: the SPA, /v1 and /api/v1 must share host and port, or the cookie does not attach; /ui/login is a redirect while /ui/login/ is a proxy; without X-Forwarded-For — and a proxy named as trusted on both sides — every caller is counted as one address; and without the map and the if at the top, the cross-site POST trap simply is not there. That last one is the failure with no symptom: everything works, and the hole is open.

One more thing this block does not do, and no proxy configuration can: it does not make it safe to run two cabinets behind it. See "One instance" above before adding an upstream.

Registration

Creating a person needs a privileged token the browser must never hold, so the Register screen posts to /api/v1/register on the cabinet's own server (server/src/api/register.rs), and the token stays there.

What one request goes through, in this order: the form itself (the account name against kanidm's iname rules, the display name, the address); a refusal for an address in a domain the secretary hands out (team.reserved_email_domains — the alias nick@altlinux.org is created at stage 2.1 of the joining procedure, so it is not anybody's to claim here); the rate limits; the captcha; an address already in use; and only then the person, created with the registrar token. The order is deliberate: the limits count attempts rather than typos, and nothing reaches kanidm before the captcha has been answered.

Whether the name is free is not asked here at all — kanidm answers it by refusing to create a second entry with that name, and the cabinet turns the 409 into "that username is already taken". All three ways of being taken arrive that way and read the same: somebody has the account; the secretary is holding the name for a candidate, as an entry whose account_expire is in the past; or an account that had it was deleted and the fork remembers the name (hmac_name_history). Which of the three it was is not a stranger's to ask.

The password never passes through the cabinet. kanidm mints a credential-update intent for the new entry and queues the mail itself; the link in it is /ui/reset?token=…, the screen below.

Mint the token with deploy/setup-kanidm.sh WITH_REGISTRATION=1 and hand it to the server as kanidm.registrar_token — unset, service_token is used, which is a wider token than this needs. [registration] enabled = false turns the form off: /api/v1/register/config tells the screen to say registration is closed, and the endpoint refuses as well.

Password reset

Both screens are the cabinet's, at the addresses kanidm's own used to answer on.

/ui/recover is the "forgot my password" form — also at #/forgot, the name the cabinet's own links write, and at #/recover. Every screen a person meets before signing in answers to its name in the fragment as well as in the path (App.svelte, route()): the two notations are both already printed in places nobody can recall, and one table decides both. It posts to /api/v1/recover on this server, behind the same captcha as registration — kanidm's own form was guarded by a proof of work of its own, and once the path became ours that guard was no longer reachable from a browser. The server forwards the address to POST /v1/recover in the fork and is told nothing back; it answers the same whether or not there is an account, so the form cannot be used to ask which addresses exist. There is a limit per caller and a limit per target address, and deliberately no global one — a fuse for everybody, tripped by one attacker, would deny the form to exactly the people who need it.

This is the one operation where a stranger chooses the target, by typing an address, so the service account must be one that cannot reach a high-privilege person: otherwise anyone who knows an administrator's address can have this service mail them a real password-reset link. The fork's route enforces that itself — it admits only members of idm_alt_portal_service, refuses a high-privilege target indistinguishably from an address nobody holds, and mints the reset with the target's own rights rather than the service account's. It also matches the address case-insensitively, which the rest of kanidm does not: somebody whose phone capitalised the first letter used to be told a letter was on its way and get nothing. deploy/setup-kanidm.sh puts the account in that group and refuses to set recovery up where it does not exist. Leave [recovery] enabled = false on anything that is not the fork — on stock kanidm there is no such route, the form answers "check your mail" to everybody, and only recovery_mail_failed with DETAIL=no-route in the trail says otherwise.

/ui/reset?token=… is the link in that mail — and in the one sent on registration, and the address in the QR code for enrolling a second device: kanidm mints one kind of token for all three. The screen exchanges it over /v1/credential/_exchange_intent and then drives the credential-update session directly: password, authenticator (with a QR code, src/lib/qr.ts), passkeys — each offered only where the session says it may be changed. The password policy, the badlist and the account's own requirements stay entirely kanidm's; the screen puts its answers into words.

kanidm does not send mail itself — it queues an outbound_message, and an external sender delivers it. That is kanidm's designed extension point, and the fork ships a sender of its own: kanidm-mail-sender (built from the fork's tools/mail_sender) is what runs on the stand, as kanidm-mail-sender.service reading /etc/kanidm/mail-sender. It drains the queue, mails the /ui/reset?token=… link with the /ui/revoke one beside it, and marks the message sent.

deploy/mail-sender.py does the same over the same API with nothing but the Python standard library, for a deployment that has not installed that binary:

MAILER_TOKEN_FILE=/path/to/mailer_token \
KANIDM_URL=https://idm.altlinux.space PUBLIC_BASE_URL=https://idm.altlinux.space \
SMTP_HOST=127.0.0.1 SMTP_PORT=25 MAIL_FROM=noreply@altlinux.org \
python3 deploy/mail-sender.py

Its default link is {base}/ui/reset?token={token}, the same address the fork's sender writes, so RESET_URL_TEMPLATE needs no overriding. Links already in inboxes written as {base}/#/reset?token=… still work — the screen answers to both.

Either sender needs an account in idm_message_senders, whose access control covers reading the queue and setting sent_at and nothing else. deploy/setup-kanidm.sh WITH_RECOVERY=1 creates that account and turns on kanidm's own recovery flag — which now gates what the cabinet uses, so recovery is silent without it; mint its token with the command the script prints.

kanidm side

deploy/setup-kanidm.sh is the runtime configuration, run as a kanidm admin with the kanidm CLI, and re-running it is safe. Always:

  • self-service over displayname, which upstream keeps behind the opt-in group idm_people_self_name_write;
  • the account policy this deployment needs — credential_type_minimum = any (above it the credential-reset page cannot be finished at all: "Finish" stays disabled however many factors are added), privilege_expiry of an hour rather than kanidm's ten minutes, and a search limit sized for thousands of people rather than kanidm's 1024, which a search does not truncate but fails;
  • the application groups other systems read by name — team, users, forgejo_admins, team_secretaries and the seven errata_* — each with the group that may maintain its membership, so the Team secretary and the errata owners are not asking an administrator every time.

With WITH_REGISTRATION=1: the cabinet's service account, in idm_alt_portal_service where the fork has that group. Where kanidm says it has no such group, nothing is granted and the run says why: that sentence is also what kanidm prints when the group exists and the account cannot see it, and the only thing that would follow from "this is stock kanidm" is granting idm_people_admins instead — which carries resetting a high-privilege person's credentials. Confirm it with STOCK_KANIDM=1 and the grant is made, loudly.

With WITH_RECOVERY=1: kanidm's own recovery flag and the mail sender's account — and a refusal if idm_alt_portal_service cannot be read, for the reason under "Password reset" above.

What it does not do is create an external sign-in provider or an OAuth2 client. Every one of those holds a secret, and a secret in a git repository is a secret that has leaked, so they are created by hand from the deployment's own copies.

It does say when a sign-in provider is missing, which is the only warning there is: a provider that has gone leaves nothing on the login page to notice. Those entries may be searched only by admin, and the search an identity administrator makes for them comes back empty rather than refused — which is indistinguishable from a provider that is not there, and is how six working providers were once all reported gone. So the list is asked for twice over: first of kanidm itself, as SYSTEM_ADMIN, where an empty answer counts as "none" only once that account has been confirmed to be in idm_oauth2_client_admins; and failing that, of the unauthenticated /ui/api/oauth2_providers, the same list the login page draws its buttons from, which needs KANIDM_URL and curl. The two lists are not identical, and a run that used the first says so: the login page drops a provider whose entry cannot produce a working sign-in — no userinfo endpoint, say — while a search returns every entry of the class, so from the entries alone a provider can count as configured and still draw no button. With neither route open the check says so and names both commands that would open one — SYSTEM_ADMIN=admin after a login as that account, or apt-get install curl. It offers nothing else: a hand-rolled request in perl or /dev/tcp would answer on some hosts and fail obscurely on others, and on the machine that authenticates everybody a predictable "install this" is worth more than a clever way around it.

Relying parties are listed and not judged. What a client is called was settled with the service on the other side and is read nowhere in the cabinet, so there is no name this repository can insist on: OAUTH2_CLIENTS is empty by default, and a deployment that does want particular ones held to names them there.

CHECK=1 deploy/setup-kanidm.sh changes nothing and only reports. Every divergence is printed with what it costs somebody and with the command that fixes it; anything the server would not answer is UNKNOWN, never ok, because a check that cannot see is the same silence being guarded against.

Two things here are not the identity administrator's at all: the domain entry (read and written by domain_admins) and the sign-in providers (searched by idm_oauth2_client_admins). The only member of either group is system_admins — admin, not idm_admin — and both refuse the same treacherous way, with an empty answer rather than an error, so a missing right reads as a missing record. SYSTEM_ADMIN=admin, with a session logged in under that name, is what gives the check those two answers; without it the check says which account it asked as, who may look, and what to re-run, rather than reporting records that are perfectly well.

It also reads the cabinet's own config and kanidm's, and says so when kanidm trusts a reverse proxy that ratelimit.trusted_proxies does not name — the pair that makes the cabinet count every caller as one address. An apply run ends with the same check, so either mode can be run on a schedule, and what it leaves with is meant to be read by whatever runs it:

exit what it means
0 everything was checked and nothing is wrong
1 at least one PROBLEM — somebody is paying for something
2 the script was used wrongly, or has a bug — including an apply asked for something this server cannot have, which stops before the report
3 nothing wrong, but at least one thing could not be checked

3 is separate from 1 deliberately. A check that could not see is not a deployment that is broken, and a job that treats them alike either wakes somebody for nothing or teaches them to ignore the alarm — which is how the settings this script exists to find went missing in the first place. Both at once exits 1.

What no script can do is the schema: the extended profile attributes and the access controls over them are part of the fork's build, not something the API accepts at runtime — docs/kanidm-schema.md has the whole finding. kanidm's domain and origin must be the cabinet's public host.

End to end

npm run e2e builds the SPA and walks a real chromium through 101 scenarios: signing in with a password and with a passkey, registering one, enrolling an authenticator, the confirmation dialog, the keys, the sessions, the public forms behind their proof of work, the screens behind a link in an email, the consent screen, both languages and both themes. It needs no stand, no database and no network — e2e/mock.mjs answers as kanidm and as this repo's own server on one loopback port, the way traefik puts them on one origin in production.

npm run e2e        # the whole set, 101 scenarios, 50-75 s on four cores
npm run e2e:fast   # signing in, passkeys and the cabinet, 36 of them, ~30 s
npm run e2e:serve  # the same fake server on :8177, to click through by hand

No new packages. Chromium is driven over the DevTools protocol through node's own WebSocket; e2e/cdp.mjs is the entire driver. Keys are answered for by chromium's own virtual authenticator over the same protocol, so the WebAuthn path is walked rather than stubbed, and the proof of work and the TOTP codes are computed by the fake server rather than agreed in advance. It wants a chromium, chromium-browser or google-chrome on the usual paths, or E2E_CHROMIUM pointing at one, and it runs headless with no interaction. A failed scenario says what was being checked, what was expected and what the screen said instead, and leaves a screenshot in $TMPDIR (E2E_SHOTS to put it elsewhere).

docs/e2e.md is how to add a case, and what the suite is deliberately not covering.

Checks

npm run lint        # eslint — the same command CI runs
npm run check       # types only: svelte-check (tsc) — 0 errors
npm test            # node's runner over src/lib/*.test.ts
npm run build       # → dist/
npm run e2e         # the browser walk-through
cargo test          # the server side, including that the example config parses

Read the exit code, not the tail of the output: eslint prints its complaint above the last line, so npm run lint | tail -1 has looked clean on a run that was red.

Releases

docs/release.md — what a release is made of, in what order, and what has gone wrong here before: the version lives in one place and the binary prints it, the lockfile moves with it, and the cabinet and the kanidm fork go out together when the release leans on the server.

Admitting people to Team

Two steps of the Team secretary's procedure land in kanidm: the account with its @altlinux.org address in users, and later the same account in team. docs/secretary.md is written for whoever does them — both ways round, with the kanidm CLI and with plain curl — and deploy/team-secretary.sh and deploy/team-secretary-api.sh are those same steps as one command each.

Layout

src/lib/kanidm.ts     kanidm /v1 and the cabinet's own /api/v1, in one client
src/lib/webauthn.ts   base64url <-> ArrayBuffer for the WebAuthn challenges
src/lib/authtext.ts   how a mechanism and a refusal are spelled for a person
src/lib/errors.ts     how a refusal, kanidm's or the server's, is put into words
src/lib/captcha.ts    the captcha the public forms are asked for
src/lib/oauth2.ts     the consent screen's side of the authorisation request
src/lib/i18n.ts       EN/RU dictionaries
src/lib/stores.svelte.ts   lang + theme (module runes)
src/lib/confirmation.ts    the conversation a privileged write waits on
src/lib/session.svelte.ts  profile + wiring the confirmation to the dialog
src/main.ts                which of the two apps a path mounts
src/App.svelte             auth gate
src/components/            Login, AuthSteps, Shell, ReauthModal, Icon, screens/*

src/admin/main.ts          the console's own entry point (admin.html)
src/admin/routes.ts        which address means which screen, and back
src/admin/router.svelte.ts the twenty lines that talk to location and history
src/admin/scim.ts          the SCIM filter, the query, and the entry shapes
src/admin/api.ts           the requests the console makes
src/admin/errors.ts        a refusal told apart from an empty answer
src/admin/i18n.ts          the console's own EN/RU dictionary
src/admin/screens/         People, Person, Groups, Group

e2e/cdp.mjs                chromium over the DevTools protocol, no packages
e2e/mock.mjs               kanidm and our server, faked on the wire, on one port
e2e/harness.mjs            a browser, a world, and assertions that say what broke
e2e/*.e2e.mjs              the scenarios: signin, passkey, cabinet, totp,
                           captcha, mail, oauth2, appearance (see docs/e2e.md)

server/src/main.rs         axum router + the SPA served from web_dist
server/src/console.rs      who is shown the administration console
server/src/config.rs       the config file, and where each secret comes from
server/src/api/            /api/v1: register, recover, me, emails, keys
server/src/kanidm/         the calls made with a service account's token
server/src/keys/           the ssh and gpg parsers a stored key goes through
server/src/nicks.rs        what a valid account name looks like

The mark

public/alt-mark.svg and public/alt-mark-inv.svg are the ALT Linux Team logo, carried over unedited except for the viewBox, which crops off the "ALT LINUX TEAM" tagline underneath: the words next to the service name would read as a stutter. The inverted file is the form the mark's owners publish for dark backgrounds — nothing here recolours the mark itself. It is a trademark, not code, and is not covered by this project's licence.

public/favicon.svg is ours, and deliberately not the logo: every ALT site would otherwise wear the same tab icon, and the point of a favicon is telling one open tab from another. It is the mark's yellow with "ID" in it, drawn as outlines because an SVG favicon has no font of its own to set the letters in.

Every field of an account maps onto a kanidm attribute; the browser keeps none of them, only the language and theme somebody chose. The extended profile fields (organization, first/last name, GPG keys, notification preferences) use custom attributes that are part of the ALT kanidm fork's build; see docs/kanidm-schema.md.