- JavaScript 27.4%
- TypeScript 25.5%
- Rust 22.2%
- Svelte 15.9%
- Shell 6.7%
- Other 2.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .forgejo | ||
| .vscode | ||
| deploy | ||
| docs | ||
| e2e | ||
| public | ||
| server | ||
| src | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| admin.html | ||
| AGENTS.md | ||
| AUTHORS | ||
| Cargo.lock | ||
| Cargo.toml | ||
| eslint.config.js | ||
| index.html | ||
| LICENSE | ||
| NEWS.md | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| svelte.config.js | ||
| TODO.md | ||
| tsconfig.app.json | ||
| tsconfig.json | ||
| tsconfig.node.json | ||
| tsconfig.test.json | ||
| vite.config.ts | ||
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 groupidm_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_expiryof 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_secretariesand the sevenerrata_*— 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.