Feature specs 4-9: branding and templates, AI spam classification, monitoring, SCIM, scale-out storage, per-domain directories

This commit is contained in:
2026-09-18 13:18:39 -07:00
parent 31e64f5a4d
commit cd7c1a6d4c
9 changed files with 3270 additions and 9 deletions
@@ -0,0 +1,462 @@
# Feature spec: AI spam classification and the LLM Sieve function
Status: draft, 2026-09-18. Feature 5 in SPEC.md §4.
## Provenance
Written for the clean room (SPEC.md §3). Sources, and nothing else:
| Source | License | Used for |
|---|---|---|
| Stalwart's registry schema: `x:AiModel`, `x:SpamLlm` and `x:SpamLlmProperties`, `x:HttpAuth`, `x:SecretKey`, `x:SpamTag`, the `AiModelType` enum, the `interactAi`, `sysAiModel*` and `sysSpamLlm*` permissions, the `ai.*` events, in `resources/schema/schema.json.gz` and `crates/registry/src/schema/*.rs` at `v0.16.22` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | The stored records, field meanings, defaults, permissions, events, what upstream flags as Enterprise |
| This repository's AGPL code: `crates/spam-filter` (the scoring order, `llm_result`, the `X-Spam-LLM` header line), `crates/common/src/scripts/plugins` (the `llm_prompt` registration), `crates/common/src/auth/permissions.rs` (default roles), `crates/trc` (event ids) | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Where the feature hooks in, what already exists, default permissions |
| This repository's shared tests: `tests/src/smtp/inbound/antispam.rs`, `tests/resources/smtp/antispam/llm.test`, `tests/resources/jmap/sieve/test_mailbox.sieve` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Expected tags, the mock endpoint's shape, the Sieve call's shape |
| Stalwart documentation (`stalwartlabs/website`): "AI Models", "LLM classifier" (spam filter), "LLM Integration" (Sieve), the `AiModel` and `SpamLlm` object references, "Scores", "Permissions", current and 0.15 versions, and the 2024-10-07 announcement post | Unlicensed public documentation: facts used, prose not copied | Behavior of the classifier, tag names, default scores, the Sieve function's signature and failure result, trusted and untrusted scripts |
| RFC 5321 §4.5.3.2.6, RFC 5322, RFC 2047, RFC 1918, RFC 4193, RFC 8620 | IETF | SMTP time limits, header syntax and encoding, private address ranges, JMAP semantics |
| The OpenAI-compatible chat and text completions request and response shape, as published and as implemented by local servers (llama.cpp's server, Ollama, vLLM, LocalAI) | Public API conventions | The wire format |
No Enterprise-only file or snippet was used. The author is a fresh session that
has never seen Enterprise code, and writes specs only. Nothing here was
observed against a running Enterprise server yet. Where no public source
settles a behavior, this spec makes a decision of its own, marked
**Decision**, or lists it under "Open questions / to observe". It never fills a
gap by guessing what upstream code does.
## What it is
Two uses of a language model the operator runs:
1. **Spam classification.** The spam filter sends a message's subject and
text to a model with the operator's prompt. The model answers with a
category and a confidence, for example `Unsolicited,High`. That becomes a
tag, such as `LLM_UNSOLICITED_HIGH`, and the tag's score is one more input
to the message's spam score. The model's opinion is one signal among many.
It never decides a message's fate alone.
2. **The Sieve function `llm_prompt`.** A Sieve script sends a prompt to a
named model and gets the answer back as a string, for example to file mail
by topic.
Both are off until the operator sets them up. inbuxa-server ships no model
and no endpoint.
**Project policy, which this spec enforces.** AI in INBUXA products must bring
real value and use a local model the operator can audit. There is no hosted
API by default. An operator may point a model at a hosted OpenAI-compatible
endpoint, but nothing is ever preset to one, and no message content leaves
the server unless the operator configured the endpoint it goes to. Docs and UI
text describe the feature as it is, including that it is AI. They never claim
the product has no AI.
Upstream ships both only in its Enterprise Edition. inbuxa-server ships them to
everybody. The fork left a signpost: `crates/common/src/scripts/plugins/llm_prompt.rs`
registers `llm_prompt` but always returns `false` (`inbuxa:` comment).
**Non-goals.** The model doesn't train the statistical classifier, move or
delete mail on its own, reply to mail, or see attachments. It isn't a
replacement for the existing filter.
## Data model
Unchanged from upstream, so existing settings open as they are (SPEC.md §7).
Both objects are server-level. They have no `memberTenantId`.
### A model endpoint, `x:AiModel` (many)
| Field | Type, default | Meaning |
|---|---|---|
| `name` | string, required | Short name. Sieve scripts name the model by it (AI-20) |
| `url` | URI, required | The OpenAI-compatible endpoint, the full path included, e.g. `…/v1/chat/completions` |
| `model` | string, required | The model name sent in each request |
| `modelType` | `AiModelType`, `Chat` | `Chat` (chat completions) or `Text` (text completions) |
| `temperature` | float 0.01.0, `0.7` | Default sampling temperature |
| `timeout` | duration, `120000` ms | How long to wait for a response |
| `allowInvalidCerts` | boolean, `false` | Accept an invalid TLS certificate |
| `httpAuth` | `x:HttpAuth` | `Unauthenticated`, `Basic` (`username`, `secret`) or `Bearer` (`bearerToken`). Secrets are `x:SecretKey`: a `Value`, an `EnvironmentVariable` or a `File` |
| `httpHeaders` | map string → string | Extra request headers |
Permissions: `sysAiModelGet`, `sysAiModelQuery`, `sysAiModelCreate`,
`sysAiModelUpdate`, `sysAiModelDestroy`. The list view shows `model` and
`modelType`, labelled by `name`.
### The classifier, `x:SpamLlm` (singleton)
Two variants by `@type`: `Disable` (the default) and `Enable`, which carries
`x:SpamLlmProperties`:
| Field | Type, default | Meaning |
|---|---|---|
| `modelId` | id of `x:AiModel`, required | The model to ask |
| `prompt` | text, required | The instructions sent with each message |
| `temperature` | float 0.01.0, `0.5` | Temperature for classification, overriding the model's |
| `separator` | string, `,` | Splits the answer into fields |
| `responsePosCategory` | unsigned, `0` | Zero-based position of the category |
| `responsePosConfidence` | unsigned or null, `1` | Position of the confidence. Null: the answer has none |
| `responsePosExplanation` | unsigned or null, `2` | Position of the explanation. Null: none |
| `categories` | set of strings, at least 2, `Commercial`, `Harmful`, `Legitimate`, `Unsolicited` | Accepted categories |
| `confidence` | set of strings, `High`, `Low`, `Medium` | Accepted confidence levels |
Permissions: `sysSpamLlmGet`, `sysSpamLlmUpdate`.
### Elsewhere
- **Tags and scores.** The classifier's tags are ordinary spam tags, scored
by `x:SpamTag` entries like every other tag: `Score` (a number), `Discard`
or `Reject`. The documented defaults are `LLM_UNSOLICITED_HIGH` 3.0 and
`LLM_LEGITIMATE_HIGH` 3.0. A tag with no entry scores 0.
- **`interactAi`** permission ("Interact with AI models"): lets an account's
own Sieve scripts call `llm_prompt`. This repository's default roles give it
to users, tenant administrators and superusers
(`crates/common/src/auth/permissions.rs`). The public permissions table
lists it for administrators only, under the older name
`ai-model-interact`. See open question 5.
- **Events:** `ai.llm-response` (id 556, a response arrived) and
`ai.api-error` (id 557, a request failed).
- **Header:** `X-Spam-LLM`, written by the existing AGPL scoring code from the
spam result's `llm_result` (a category string and an explanation) as
`X-Spam-LLM: {category} ({explanation})`.
### Added by inbuxa-server
A server-level singleton for the fork's limits, in the fork's own namespace
(name open, SPEC.md §8), so upstream's records stay exactly as upstream wrote
them. **Decision**: these are new, and every default below is this spec's
own.
| Field | Default | Meaning |
|---|---|---|
| `spamMaxAdded` | `5.0` | Most an LLM tag can add to a message's score (AI-12) |
| `spamMaxSubtracted` | `1.0` | Most an LLM tag can take off a message's score (AI-12) |
| `spamCallCeiling` | `20s` | Longest the spam filter waits for the model, whatever the model's `timeout` (AI-9) |
| `maxConcurrentCalls` | `4` | Model requests in flight at once, across both uses, per server node (AI-10) |
| `maxContentBytes` | `16384` | Most message text sent per classification (AI-4) |
| `failureBackoff` | `60s` | Pause after repeated failures (AI-11) |
| `userCallsPerHour` | `60` | `llm_prompt` calls per account per hour from its own scripts (AI-24) |
## Required behavior
### Defaults and privacy
- **AI-1.** A new install has no `x:AiModel` and `x:SpamLlm` set to
`Disable`. Nothing is sent to any model until an administrator creates one.
No URL, model name, prompt or example anywhere in the product's defaults,
placeholders, docs or setup points at a hosted provider. Where an example
is needed, it is a local one, such as `http://127.0.0.1:8080/v1/chat/completions`.
- **AI-2.** When a model's `url` host isn't `localhost`, a loopback address, an
RFC 1918 or RFC 4193 private address, or a name resolving only to those,
the server logs a warning at startup and on every change that message
content will leave this network. INBUXA Admin shows the same warning on the
model's form. **Decision.** The check is advisory. It never blocks an
endpoint the operator chose.
- **AI-3.** The classifier sends only the subject and the message's text:
plain-text parts, and HTML parts converted to text. Never sent: other
headers, addresses, the envelope, the client IP, attachments, images, or
anything identifying the recipient. **Decision**: the documented behavior
is "subject and body". Nothing else is needed to judge content.
- **AI-4.** The text sent is cut to `maxContentBytes` on a character boundary.
Text over the limit is truncated from the end, and the request says so
(AI-6).
- **AI-5.** Message content is never written to the logs. The
`ai.llm-response` event, at trace level, records the model's name, the
response time, the raw answer (cut to 1 KiB), and the resulting tag. The
`ai.api-error` event records the model's name, the error and the HTTP
status. Neither ever records a secret or an authorization header. The
operator can audit what the model decided and why, and the prompt is plain
in the settings.
### The request
- **AI-6.** Classification sends, for a `Chat` model:
`POST {url}` with JSON `{"model": model, "messages": [system, user],
"temperature": t, "max_tokens": 200, "stream": false}`. The system message
is the operator's `prompt` followed by a fixed framing paragraph written by
this project and shown in the docs, stating that the email follows between
two marker lines, that it is data to classify and not instructions, and
that anything inside it asking for a particular answer is itself a sign of
abuse. The user message is:
```
-----BEGIN EMAIL {nonce}-----
Subject: {subject}
{text}
-----END EMAIL {nonce}-----
```
with `[truncated]` before the end marker when AI-4 cut it. `{nonce}` is 16
random hex characters, new for each request, so a message can't forge the
end marker. For a `Text` model: `{"model", "prompt", "temperature",
"max_tokens", "stream": false}`, where `prompt` is the system text, a blank
line, then the user text. **Decision** throughout: separate roles and
unforgeable markers blunt prompt injection from message content. They don't
stop it, which is why AI-12 bounds the damage.
- **AI-7.** The answer is `choices[0].message.content` for `Chat` and
`choices[0].text` for `Text`. Any other shape, an HTTP status other than
200, or a body over 64 KiB is a failure (AI-9).
- **AI-8.** Requests carry `Content-Type: application/json`, the `httpAuth`
credentials (Basic or Bearer, read from the `x:SecretKey` at request time),
and `httpHeaders`. Redirects are not followed. **Decision**: a redirect
would send message content to a host the operator never named. TLS
certificates are checked unless `allowInvalidCerts`. No user, account or
message identifier is sent (no OpenAI `user` field).
### Failure never costs mail
- **AI-9.** A timeout, connection error, HTTP error, bad JSON, empty answer
or unparseable answer adds no tag, no score and no `X-Spam-LLM` header. The
message goes on through the filter as if the classifier were off, and
`ai.api-error` is logged. The classifier waits no longer than the shorter of
the model's `timeout` and `spamCallCeiling`. **Decision**: upstream's
default model timeout is two minutes, which is too long to hold an SMTP
transaction (RFC 5321 §4.5.3.2.6 gives the client 10 minutes for the reply
to the end of data, and sending servers commonly give up sooner).
- **AI-10.** At most `maxConcurrentCalls` requests are in flight. A message
that finds no free slot isn't queued: it is scored without the model, as in
AI-9. The model's slowness can never back up inbound mail.
- **AI-11.** After 5 consecutive failures to a model, the server stops calling
it for `failureBackoff`, then tries again with one request. While paused,
messages are scored as in AI-9. The pause and the resume are each logged
once. **Decision.**
### From the answer to a tag
- **AI-12.** Parsing the answer:
1. Trim it and take the first non-empty line.
2. Split it by `separator`, taken as the whole string. An empty separator
is refused at `/set` with `invalidProperties`.
3. Take the fields at `responsePosCategory` and, when not null,
`responsePosConfidence`. Trim each of spaces and the characters
`" ' * .`. Match each case-insensitively against `categories` and
`confidence`. A field that's missing or matches nothing means no tag.
4. The tag is `LLM_` + category, or `LLM_` + category + `_` + confidence
when there is a confidence, using the configured spelling, uppercased,
with every character outside `A-Z` and `0-9` replaced by `_`. So
`Unsolicited` and `high` give `LLM_UNSOLICITED_HIGH`.
5. When `responsePosExplanation` isn't null, the explanation is the field
at that position. When it's the last position used, it runs to the end of
the line, separators included, since explanations contain commas.
**Decision** on steps 1, 3 and 5. The documented rule is that answers
outside the configured sets are ignored.
- **AI-13.** One classification gives at most one tag. Its score comes from
`x:SpamTag` as for any tag, then is clamped to `[spamMaxSubtracted,
+spamMaxAdded]`. A `Discard` or `Reject` entry on a tag starting `LLM_`
counts as no entry (score 0) and logs a warning at load. **Decision**: the
model reads attacker-written text, so its word alone must never refuse or
destroy mail, and it can pull a score down only a little, because "this is
legitimate" is exactly the answer an injected message asks for. The
operator's stored `x:SpamTag` records are left as they are.
- **AI-14.** The classifier's tag and score appear in `X-Spam-Result` like any
other. The model's output never trains the statistical classifier, never
counts toward auto-learn, and plays no part when a user reports mail as spam
or not spam.
- **AI-15.** When a tag was assigned, the message gets one `X-Spam-LLM`
header: `X-Spam-LLM: {TAG} ({explanation})`, or `X-Spam-LLM: {TAG}` with no
explanation. The explanation is cut to 200 characters. Control characters,
CR and LF included, and parentheses are removed. Non-ASCII text is encoded
as RFC 2047 encoded words, and the header is folded to RFC 5322's line
limits. **Decision** on the format, see open question 3. Any `X-Spam-LLM`
header already in an inbound message is removed first, so a sender can't
plant one.
### When it runs
- **AI-16.** With `x:SpamLlm` set to `Enable`, the classifier runs on every
message that goes through the spam filter, except:
- mail from an authenticated sender (local users' own mail isn't sent to a
model). **Decision**;
- when the filter has already reached a `Discard` or `Reject` result from
another tag, where the answer can't change anything. **Decision**.
- **AI-17.** It runs after every other analysis step and before user-defined
rules (`x:SpamRule`) and the final score, so rules can test the `LLM_` tags.
**Decision** on the position.
- **AI-18.** A `modelId` pointing at no model is refused at `/set`. Destroying
a model that `x:SpamLlm` names is refused. **Decision**, see open question
6. A settings change takes effect without a restart.
- **AI-19.** Reloading or changing settings never drops mail in flight. A
classification already under way finishes, or fails as in AI-9.
### The Sieve function `llm_prompt`
- **AI-20.** `llm_prompt(model, prompt, temperature)`, available with
`require "vnd.stalwart.expressions"`. `model` names an `x:AiModel` by its
`name`, or failing that by its id. `prompt` is sent as is. `temperature` is
clamped to 0.01.0. A value that isn't a number uses the model's own
`temperature`. **Decision** on name first, see open question 4.
- **AI-21.** For a `Chat` model the request is one user message holding the
prompt. For `Text`, the prompt itself. `max_tokens` is 1,000 and
`stream: false`. The same rules as AI-7 and AI-8 apply. The script builds
its own prompt, so no framing is added.
- **AI-22.** It returns the answer, trimmed and cut to 8 KiB, as a string.
On any failure (unknown model, no permission, rate limit, timeout, error) it
returns `false`, as documented, and logs `ai.api-error`. The script carries
on. The answer is always plain data. It's never evaluated as an expression
or as Sieve.
- **AI-23.** Trusted scripts (the system scripts run at SMTP stages) can
always call it. An account's own scripts can only when the account holds
`interactAi`. The prompt is cut to 32 KiB. The wait is the shorter of the
model's `timeout` and `spamCallCeiling` in trusted scripts. In an account's
own scripts it is the model's `timeout`, capped at 60 s. **Decision**.
- **AI-24.** An account's own scripts may make `userCallsPerHour` calls an
hour and one at a time. Calls over the limit return `false` at once. Calls
share the `maxConcurrentCalls` slots and the back-off in AI-10 and AI-11.
**Decision**: without this, any user could keep a CPU-only model busy for
everyone.
- **AI-25.** Every account-script call logs the account, the model's name and
the response time, never the prompt, so an operator can see who uses the
model and how much.
### Administration
- **AI-26.** `x:AiModel` and `x:SpamLlm` are available on every server,
answering normally, with no edition check. The Enterprise upsell error in
`crates/jmap/src/registry/mod.rs` no longer applies to them.
- **AI-27.** They're server-level. A tenant administrator can't read or change
them, whatever its role, since a model's URL and secrets are server
configuration. Tenant users' scripts can still call models they know the
name of, subject to AI-23 and AI-24.
- **AI-28.** Secrets in `httpAuth` are never returned by `/get`, as for every
other `x:SecretKey` field.
## Interfaces
- **Existing, unchanged:** `x:AiModel/get`, `/query`, `/set`; `x:SpamLlm/get`
and `/set` (singleton); the field names, enums and defaults above; the
permission names; the `ai.llm-response` and `ai.api-error` events; the
`LLM_*` tag names; the `X-Spam-LLM` header name; the Sieve function name,
arity and `false`-on-failure result.
- **Errors:** RFC 8620 `SetError` types. `invalidProperties` names the field
(an empty `separator`, a `categories` set under 2, an out-of-range
`temperature`, a `modelId` that doesn't exist). Destroying a model in use is
refused with the registry's existing error for a linked object, naming
`x:SpamLlm`.
- **New:** the limits singleton, in the fork's namespace.
- **The model's wire format** is AI-6 to AI-8 and AI-21. Any server that speaks
the OpenAI-compatible chat or text completions API works. The docs name
local servers first.
## ihasmail changes
These go in the INBUXA fork of ihasmail, ihasmail-inbuxa, never in public
ihasmail, which stays Stalwart-facing (SPEC.md §5).
- **Reading:** when a message has an `X-Spam-LLM` header, the message details
(and the Junk banner, when the message is in Junk) show "Language model's
opinion" with the tag's category and confidence and the explanation. It's
labelled as one signal among several, never as the reason on its own.
- **Administration:** nothing new. Model and classifier settings are server
configuration, and live in INBUXA Admin (SPEC.md §5.4). ihasmail's dashboard
may link there.
- **Translation:** 2 new strings ("Language model's opinion" and "One of
several signals the spam filter weighed"), each needed in all nine language
catalogues: 18 entries. Categories and explanations come from the server
and aren't translated.
**INBUXA Admin** (schema-driven, so it picks up both objects with no work)
needs only: the locality warning on the model form (AI-2); a local example
URL as the `url` placeholder; the fork's default prompt prefilled when the
classifier is switched to `Enable` (see below); and a note on the classifier
page that failures never hold up mail.
**Default prompt.** Upstream's documented prompt isn't copied. The fork's
default, prefilled only when an administrator enables the classifier, is this
project's own:
> Classify the email below as one of: Unsolicited, Commercial, Harmful,
> Legitimate. Unsolicited: bulk mail the recipient didn't ask for. Commercial:
> selling something. Harmful: phishing, fraud or malware. Legitimate: anything
> else. Then give your confidence: High, Medium or Low. Answer on one line as
> Category,Confidence,Reason with a reason of at most 20 words.
## Acceptance tests
Every test runs against inbuxa-server built with no Enterprise code. None
needs a hosted model. Each uses a local stub endpoint on loopback, spawned by
the test (the suite already has `spawn_mock_http_server`), which records what
it received and answers as each test needs. The gated `llm` case in
`tests/src/smtp/inbound/antispam.rs` is re-enabled, with its mock updated to
read the last message rather than the first (AI-6).
1. Fresh install: no `x:AiModel`, `x:SpamLlm` is `Disable`, and the stub
receives nothing while mail flows (AI-1).
2. The twelve cases in `llm.test`: stub answers `Unsolicited,High,Test` and
the rest give `LLM_UNSOLICITED_HIGH` and so on (AI-12).
3. Stub answers `unsolicited , HIGH , Lots of commas, here` gives
`LLM_UNSOLICITED_HIGH`, and the header's explanation is
`Lots of commas, here` (AI-12, AI-15).
4. Stub answers `Maybe,High,x`, an empty body, and text with no separator: no
tag, no header, message delivered (AI-9, AI-12).
5. `responsePosConfidence: null` gives `LLM_UNSOLICITED` (AI-12).
6. The request the stub receives: system message is the prompt plus framing;
user message carries the subject, the text, and markers with a fresh nonce
each time; no addresses, other headers or attachment content anywhere
(AI-3, AI-6, AI-8).
7. A 100 KiB body: the stub receives at most `maxContentBytes` of text and
`[truncated]` (AI-4).
8. Stub never answers: the message is delivered within `spamCallCeiling`
plus normal processing time, with no tag (AI-9).
9. Stub down for 5 messages: the next messages are scored without calling it
until `failureBackoff` ends, then one probe request (AI-11).
10. `maxConcurrentCalls` 1, stub slow, two messages at once: one is
classified, the other is delivered without a tag (AI-10).
11. `LLM_UNSOLICITED_HIGH` scored 50: the message's score rises by 5.0.
`LLM_LEGITIMATE_HIGH` scored 50: it falls by 1.0.
`LLM_HARMFUL_HIGH` set to `Reject`: the message isn't rejected (AI-13).
12. Stub's explanation contains CRLF, a fake header and non-ASCII text: one
well-formed `X-Spam-LLM` header, RFC 2047-encoded. An inbound message
carrying its own `X-Spam-LLM` loses it (AI-15).
13. An authenticated submission: the stub receives nothing (AI-16).
14. A `SpamRule` testing `LLM_HARMFUL_HIGH` fires (AI-17).
15. Stub replies with a 302 to another loopback port: not followed, counted
as a failure (AI-8).
16. Bearer and Basic auth reach the stub, read from `Value`,
`EnvironmentVariable` and `File` secrets. `/get` doesn't return them. The
logs don't contain them (AI-5, AI-8, AI-28).
17. `llm_prompt('echo-test', 'hello world', 0.5)` in `test_mailbox.sieve`,
with an `x:AiModel` named `echo-test` pointing at an echoing stub, returns
`hello world` (AI-20 to AI-22).
18. `llm_prompt` with an unknown model, a failing stub, and from an account
without `interactAi`: each returns `false` and delivery continues (AI-22,
AI-23).
19. The 61st call in an hour from one account's script returns `false`
without reaching the stub (AI-24).
20. A tenant administrator gets `forbidden` on `x:AiModel/get` and
`x:SpamLlm/set` (AI-27).
21. A model on `https://mail.example.net/…` logs the locality warning. One on
`127.0.0.1` or `10.0.0.5` doesn't (AI-2).
22. **(compat)** INBUXA's `x:AiModel` and `x:SpamLlm` records, if any, and
its `LLM_*` `x:SpamTag` entries read back unchanged after cutover.
## Open questions / to observe
To check read-only against INBUXA's live Enterprise server later, as the
throwaway account and an administrator, with no settings changed:
1. Whether INBUXA has any `x:AiModel` (`/query`) or `x:SpamLlm` set to
`Enable`, and where any model points. If none, compat test 22 has nothing
to carry.
2. The `LLM_*` entries in `x:SpamTag/query`, and their scores. They show
upstream's defaults for the ten tags not documented.
3. The exact `X-Spam-LLM` format upstream writes, from any message in the
mail store that carries one. Only possible if INBUXA ever ran the
classifier. AI-15 is a **Decision** until then.
4. Whether `llm_prompt` names a model by `name` or by id. The shared Sieve
test uses `echo-test`, which reads like a name. Where upstream's
`echo-test` model comes from in its tests isn't settled by any allowed
source, so test 17 creates it.
5. Whether ordinary accounts hold `interactAi`, from an account's effective
permissions. This repository's defaults say yes; the public table says
administrators only.
6. What upstream does when a model that `x:SpamLlm` uses is destroyed (only
observable by changing settings, so it needs the operator's approval and a
stub model). AI-18 is a **Decision** meanwhile.
7. Whether upstream classifies authenticated submissions, and what it does on
a timeout (again, needs a temporary stub model and the operator's
approval).
Not for observation, but open:
8. A per-account or per-domain opt-out from classification, for users who
don't want their mail read by a model even locally. Not in upstream's
schema. A later addition in the fork's namespace if asked for.
9. A "test this model" action for INBUXA Admin. Useful, not required.
10. The name of the fork's limits singleton, with the namespace (SPEC.md §8).
@@ -0,0 +1,380 @@
# Feature spec: branding and templates
Status: draft, 2026-09-18. Feature 4 in SPEC.md §4.
## Provenance
Written for the clean room (SPEC.md §3). Sources, and nothing else:
| Source | License | Used for |
|---|---|---|
| Stalwart's registry schema: `crates/registry/src/schema/*.rs` and `resources/schema/schema.json.gz`, as imported into this repository (v0.16.22) | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | The five flagged fields, their types, descriptions and defaults, the related settings, permission prefixes |
| This repository's shared code: the template syntax (`crates/utils/src/template.rs`), the variable names (`crates/common/src/config/groupware.rs`), the alarm and iMIP senders (`crates/services/src/task_manager/alarm.rs`, `imip.rs`), the RSVP API types (`crates/groupware/src/calendar/itip.rs`), the HTTP routes (`crates/http/src/request.rs`), the logo cache and its invalidation (`crates/common/src/lib.rs`, `cache/invalidate.rs`), and the default pages and templates (`resources/html-templates/`, `resources/branding/`) | AGPL-3.0-only OR LicenseRef-SEL, with Enterprise-only parts already stripped; the pages and logo are the fork's own | What the built-in templates receive, how they're rendered and escaped, what the pages call, where hooks go |
| Stalwart documentation: "Branding" (`docs/management/webui/branding.md` and the 0.15 `webadmin/branding.md`), "Scheduling" (`docs/collaboration/scheduling.md`, sections HTTP RSVP and Branding and templating), the Enterprise and CalendarAlarm object references | Unlicensed public documentation: facts used, prose not copied | The logo order, hostname-based selection, the RSVP page contract, which templates exist |
| RFC 2397 (`data:` URLs), RFC 2392 (`cid:` URLs), RFC 5546 (iTIP), RFC 6047 (iMIP), RFC 8620 | IETF | Logo value forms, how the email logo is referenced, the messages the templates dress, `/set` errors |
No Enterprise-only file or snippet was used. The author is a fresh session
that has never seen Enterprise code. No server was probed for this spec. Where
no public source settles a behavior, this spec makes a decision of its own,
marked **Decision**, or lists it under "Open questions / to observe". It never
fills a gap from memory of upstream code.
## What it is
An operator can put its own logo, and its own layout, on what the server
shows and sends to people:
- **Logos.** One server-wide logo, one per tenant, one per domain. The most
specific one that applies is used on the server's sign-in page, the RSVP
page, calendar alarm and invitation emails, and in ihasmail.
- **Templates.** The HTML of calendar alarm emails, of iMIP invitation emails
(invitations, updates, cancellations and replies), and the whole HTTP RSVP
page.
Upstream ships this only in its Enterprise Edition. inbuxa-server ships it to
everybody. INBUXA's own branding is the default: the fork already ships
rebranded built-in templates, an INBUXA email logo
(`resources/branding/email-logo.png`), and `INBUXA Calendar` as the alarm
sender name.
Today, in the stripped tree, the five fields read and write normally but do
nothing: the built-in templates are always used, `logo_resource` always
answers none, and there's no `/logo` route. The login and RSVP pages already
ask `/logo` and fall back to their built-in logo when it fails.
Tenant logos (`x:Tenant.logo`) and the per-principal logo over JMAP are in
multi-tenancy MT-22 and MT-23. This spec uses them and doesn't repeat them.
## Data model
Unchanged from upstream, so existing data opens as it is (SPEC.md §7). All
five flagged fields are nullable strings. Null means "use the built-in".
| Object | Field | Schema type | Meaning |
|---|---|---|---|
| `x:Enterprise` (singleton) | `logoUrl` | `Uri?` | The server-wide default logo |
| `x:Domain` | `logo` | `String?` | The domain's logo: "URL or base64-encoded image" |
| `x:CalendarAlarm` (singleton) | `template` | `Html?` | Replaces the built-in alarm email |
| `x:CalendarScheduling` (singleton) | `emailTemplate` | `Html?` | Replaces the built-in iMIP email |
| `x:CalendarScheduling` (singleton) | `httpRsvpTemplate` | `Html?` | Replaces the built-in RSVP page. "Served verbatim", and responsible for calling `/api/calendar/rsvp` itself |
Related, not flagged, and unchanged:
- `x:Tenant.logo` (multi-tenancy spec).
- `x:CalendarAlarm`: `enable`, `fromName` (default `INBUXA Calendar` in the
fork), `fromEmail`, `allowExternalRcpts`, `minTriggerInterval`.
- `x:CalendarScheduling`: `enable`, `httpRsvpEnable` (default true),
`httpRsvpUrl`, `httpRsvpLinkExpiry` (default 90 days), `autoAddInvitations`,
`itipMaxSize`, `maxRecipients`.
- `x:Enterprise.licenseKey` and `apiKey`. There's no license (SPEC.md §4).
**Decision**: they stay readable and writable so existing data round-trips,
and are ignored.
- `x:OAuthClient.logo`, shown on the consent page (contract C-9). Not part of
this feature, but it follows BT-3 and BT-7 too.
Permissions, all existing: `sysEnterpriseGet` and `sysEnterpriseUpdate`,
`sysCalendarAlarmGet` and `sysCalendarAlarmUpdate`,
`sysCalendarSchedulingGet` and `sysCalendarSchedulingUpdate`, and
`sysDomainUpdate` for a domain's logo. The names stay as they are, "Enterprise"
included: they're protocol identifiers (SPEC.md §2.4).
## Required behavior
Each requirement has an ID, and tests name the IDs they check.
### Logos: which one applies
- **BT-1.** Logos are resolved for a domain name. For a name D:
1. the `logo` of the `x:Domain` whose `name` or `aliases` match D;
2. else the `logo` of that domain's tenant (`memberTenantId`);
3. else `x:Enterprise.logoUrl`;
4. else the built-in INBUXA logo.
This is the order upstream documents. **Decision** on matching: exact match
first, then D with its leftmost label removed, repeated while at least two
labels remain, so `mail.example.com` finds `example.com`. Matching is
case-insensitive.
- **BT-2.** Where each surface gets its domain:
- the sign-in page and the RSVP page: the `domain` query parameter of
`/logo` (BT-5), else the request's `Host` without its port, as upstream
documents;
- alarm emails: the domain of the account's primary address;
- iMIP emails: the domain of the message's `From` address;
- ihasmail: the signed-in principal's domain (multi-tenancy MT-22).
**Decision**: MT-22's chain ends at "none". This spec extends it with steps
3 and 4, so ihasmail and the server show the same logo. The multi-tenancy
spec should be updated to point here.
- **BT-3.** A logo value is one of:
- an `https:` URL;
- a `data:` URL (RFC 2397), base64, with type `image/png`, `image/jpeg`,
`image/gif`, `image/webp` or `image/svg+xml`.
**Decision** on writes: anything else is refused with `invalidProperties`
naming the field, and so is a data URL whose decoded image is over 256 KiB
or whose bytes don't match its declared type. `logoUrl` takes the same two
forms: a data URL is a valid URI.
- **BT-4.** Stored values that predate the fork are read as they are, never
rewritten, and never refused on read. **Decision** on odd ones: a bare
base64 string (the schema says "base64-encoded image") is treated as a data
URL whose type is sniffed from its first bytes, and an `http:` URL is used
like an `https:` one. A value that is none of these is skipped as if unset,
with a `registry.build-warning` event naming the object.
### Logos: serving and embedding
- **BT-5.** `GET /logo`, anonymous, rate-limited like the other anonymous
endpoints. It resolves a logo per BT-1 and BT-2 and answers:
- a data-URL logo: `200` with the decoded bytes and their type;
- a URL logo: `302` to that URL;
- no custom logo at any level: `404`, and the page draws its own.
Every answer carries `Cache-Control: public, max-age=300`,
`X-Content-Type-Options: nosniff`, and `Access-Control-Allow-Origin: *`.
**Decision** on the shape (see open question 3).
- **BT-6.** An unknown domain answers exactly as a known domain with no logo
of its own would: the server-wide logo or `404`. **Decision**: `/logo` isn't
a way to test which domains are hosted, beyond what a domain's own logo
shows.
- **BT-7.** The server never fetches a logo URL, for any purpose: not to serve
it, not to check it, not to embed it. Only browsers and mail clients fetch
URL logos, and ihasmail through its image proxy (MT-23).
- **BT-8.** An SVG served by `/logo` is sent with
`Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`.
**Decision**, a security requirement: a tenant administrator can set its
domain's logo, and `/logo` is on the server's origin, so a scripted SVG must
never run there.
- **BT-9.** Emails embed the logo as an inline MIME part inside the
`multipart/related` part, and the template reaches it through `{{logo_cid}}`,
a `cid:` URL (RFC 2392). This is what the shared code already does. Only a
PNG, JPEG or GIF data-URL logo is embedded. **Decision**: a URL logo is never
embedded (BT-7), and SVG and WebP are skipped because many mail clients
can't show them. Resolution then goes on down BT-1's chain for the first logo
that can be embedded, ending at the built-in PNG.
- **BT-10.** A logo change takes effect without a restart, on every node of a
cluster, through the existing `DomainLogo` and `TenantLogo` cache
invalidations. A change to `logoUrl` clears the whole logo cache.
### Email templates
- **BT-11.** A template set in `x:CalendarAlarm.template` replaces the
built-in alarm email, and one set in `x:CalendarScheduling.emailTemplate`
replaces the built-in iMIP email. Null restores the built-in. Both are
server-wide: there's no per-tenant or per-domain template. The logo is what
varies (BT-9).
- **BT-12.** The template language is the one the built-in templates already
use (`crates/utils/src/template.rs`):
- `{{name}}` inserts a value, HTML-escaped (`& < > " '`);
- `{{#if name}}…{{/if name}}` keeps its content only when `name` is set;
- `{{#each name}}…{{/each name}}` repeats its content once per entry of a
list, and inside it `{{name}}` and `{{#if name}}` refer to the entry.
A token can't span lines. `#each` can't be nested.
- **BT-13.** The variables. What the server sets for each template:
| Variable | Kind | Alarm | iMIP |
|---|---|---|---|
| `page_title` | value | the subject | the subject |
| `lang`, `dir` | value | recipient's locale and direction | same |
| `logo_cid` | value | `cid:` of the logo part | same |
| `header` | value | the alarm heading | on update, cancel and reply: what happened |
| `color` | value | — | `info`, `warning` or `danger`, with `header` |
| `event_title`, `event_description` | value | if the event has them | same |
| `event_details` | list of `key`, `value`, `link`?; iMIP also `changed`?, `old_value`? | start, end, location, conference, organizer | summary, description, when, location, conference; the old value when it changed |
| `attendees_title` | value | always | when there are attendees |
| `attendees` | list of `key` (name), `value` (address) | when there are guests | when there are attendees |
| `action_name`, `action_url` | value | "open" label and the event's webcal link | — |
| `rsvp` | value | — | "reply as …", when RSVP links are on |
| `actions` | list of `action_name`, `action_url`, `color` | — | yes, no and maybe, when RSVP links are on |
| `footer` | alarm: value; iMIP: list of `key` | the footer line | two footer lines |
`link` is set only for `https`, `http`, `tel`, `sip`, `sips` and `xmpp`
values, so a template can't be made to link `javascript:`. The labels are the
server's own translations for the recipient's locale. A template can't add
translated text of its own.
- **BT-14.** **Decision**, a security requirement: values are always escaped
in operator templates. The shared syntax also has `{{!name}}` for raw output.
A write that uses it is refused (BT-15). A stored template that uses it
(from before the fork) is rendered with those values escaped too. Event titles,
descriptions and attendee names come from whoever sent the invitation, often
from outside the server, so raw output would let a stranger put HTML into
mail the server sends under its own name.
- **BT-15.** A write of `template` or `emailTemplate` is checked, and refused
with `invalidProperties` naming the field and the first problem, when:
- it doesn't parse (unbalanced block, block end that doesn't match, token
across lines, nested `#each`);
- it names a variable not in BT-13;
- it uses `{{!…}}`;
- it's over 256 KiB.
**Decision** on all four. A variable used outside the scope where it's set
(e.g. `{{key}}` outside a list) renders empty. That's allowed, not refused.
- **BT-16.** The server fetches nothing a template references, and doesn't
rewrite it. A remote image in an operator's template is the recipient's mail
client's business. The plain-text part of each email is still generated from
the rendered HTML, as today.
- **BT-17.** Subjects, sender names and addresses aren't templated. They stay
as the shared code builds them, with `fromName` and `fromEmail` for alarms.
- **BT-18.** A template change takes effect for the next email rendered, with
no restart and no settings reload. **Decision**, matching undelete UD-6a:
whether upstream needs a reload is open question 4.
- **BT-19.** A stored template that fails BT-15's parse on load (data written
before the fork) never stops the server. The built-in is used instead, and a
`registry.build-warning` event names the field and the error. It's reported
again whenever settings are reloaded until it's fixed.
### The RSVP page
- **BT-20.** When `httpRsvpEnable` is true, `GET /calendar/rsvp` serves
`httpRsvpTemplate` if set, else the built-in page. A custom page is served
byte for byte: no variables, no substitution. `{{` in it is plain text.
Setting `httpRsvpEnable` to false turns off both the page and the API, as
upstream documents.
- **BT-21.** Whichever page is served, the answer carries:
- `Content-Type: text/html; charset=utf-8` and `Cache-Control: no-store`;
- `Referrer-Policy: no-referrer`, because the token is in the query string;
- `Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`.
**Decision**, a security requirement: the page holds a live RSVP token. It
may show remote images (URL logos, BT-5), but it can't send the token
anywhere but the server.
- **BT-22.** A write of `httpRsvpTemplate` is refused with `invalidProperties`
when it's over 1 MiB or isn't valid UTF-8. **Decision**. Its content isn't
otherwise checked: it's the operator's own page, and only a server-level
administrator can set it (BT-24).
- **BT-23.** The API a page calls is unchanged: `POST /api/calendar/rsvp` with
`{token}` to load the invitation, and `{token, partstat, comment}` to reply.
The answer is `invitation`, `recorded` or `error`, with localized `labels`, a
`language` and a `dir`, as the scheduling documentation describes. This
spec adds nothing to it.
### Who can change what
- **BT-24.** The three templates and `logoUrl` live on server-wide singletons.
Only a principal in no tenant, holding the matching `sys*Update`
permission, can change them. A principal in a tenant can't, whatever its
permissions (multi-tenancy MT-2).
- **BT-25.** A domain's `logo` is changed with `sysDomainUpdate`. Inside a
tenant, the tenant's administrator can set the logo of its own domains
(multi-tenancy MT-11). A tenant's `logo` is server-level (MT-12).
### Built-in pages
- **BT-26.** The built-in sign-in and RSVP pages load the logo with an image
element pointing at `/logo` (with `?domain=` as they do now), not with
`fetch`. On an error they keep their built-in logo. **Decision**: `fetch`
can't follow BT-5's redirect to another origin without that origin's CORS
headers, and an image element can.
## Interfaces
- **Existing, unchanged:** `x:Enterprise/get` and `/set`,
`x:CalendarAlarm/get` and `/set`, `x:CalendarScheduling/get` and `/set`,
`x:Domain/get` and `/set`, their permissions; `GET /calendar/rsvp`;
`POST /api/calendar/rsvp`.
- **New:** `GET /logo` (BT-5), with an optional `domain` parameter. The fork's
own pages already call it.
- **Per principal:** the applicable logo over JMAP, as proposed in
multi-tenancy MT-22, following BT-1's full chain (BT-2).
- **Errors:** RFC 8620 `invalidProperties`, naming the field, with the parse
error or limit in `description`, so ihasmail and INBUXA Admin can show it.
- **Events:** `registry.build-warning` for BT-4 and BT-19.
## ihasmail changes
These go in the INBUXA fork of ihasmail, ihasmail-inbuxa, never in public
ihasmail, which stays Stalwart-facing (SPEC.md §5).
- **Administration, Domains:** a logo field on each domain. Upload a PNG, JPEG
or GIF, stored as a data URL, with a preview and the 256 KiB limit checked
before sending. A note says that SVG, WebP and URL logos show on the web but
aren't used in email (BT-9). Tenant logos stay where multi-tenancy puts them.
- **Show the applicable logo** for the signed-in user (MT-22, BT-2). URL logos
go through the image proxy (MT-23).
- **Not in ihasmail:** the server-wide logo and the three templates are
server-level settings. INBUXA Admin's schema-driven forms already cover them
(SPEC.md §5.4). ihasmail links there, and doesn't grow a template editor.
- New strings: the logo field's label, its help text, the email note, and the
size and type errors. That's about six strings, new translation work for
each of ihasmail's nine languages.
INBUXA Admin needs no new screen. It shows the `invalidProperties`
descriptions from BT-15 and BT-22 as it shows any validation error.
## Acceptance tests
Every test runs against inbuxa-server built with no Enterprise code. The one
marked **(compat)** also runs against a copy of INBUXA's data.
1. Nothing set: `/logo` answers `404`, and alarm and invite emails carry the
built-in INBUXA PNG, referenced by `cid:` (BT-1, BT-5, BT-9).
2. `logoUrl` set to a PNG data URL: `/logo` answers `200` `image/png` with the
bytes, and emails embed it (BT-1, BT-5, BT-9).
3. Tenant T with a logo, its domain without one: `/logo?domain=` for the
domain gives T's logo. With the domain's own logo set, the domain's wins
(BT-1).
4. `/logo?domain=mail.example.com` finds `example.com`'s logo. No parameter,
`Host: mail.example.com`: the same (BT-1, BT-2).
5. A domain logo that's an `https:` URL: `/logo` answers `302` to it, the
server makes no outbound request (watched at the network), and emails fall
back to the next logo that can be embedded (BT-5, BT-7, BT-9).
6. An unknown domain and a known domain with no logo give identical answers
(BT-6).
7. An SVG logo with a `<script>`: `/logo` sends the sandboxing CSP, and the
script doesn't run when the URL is opened directly (BT-8).
8. Logo writes: `javascript:alert(1)`, `data:text/html,…`, a 300 KiB PNG, and
`data:image/png` holding JPEG bytes are each refused `invalidProperties`
(BT-3).
9. Changing a domain's logo shows on the next `/logo` request on another
cluster node, without a restart (BT-10).
10. A custom alarm template renders with every BT-13 alarm variable filled.
An event titled `<b>x</b>` shows as text, not bold (BT-12, BT-13, BT-14).
11. A custom iMIP template for an invitation, an update (with `changed` and
`old_value`), a cancellation and a reply each render, with RSVP actions
only when RSVP is on (BT-13).
12. Template writes: unbalanced `{{#if header}}`, `{{unknown}}`, `{{!header}}`,
nested `#each`, and 300 KiB are each refused `invalidProperties`, naming
the field (BT-15).
13. A template stored directly in the registry with `{{!event_title}}` renders
the title escaped. One that doesn't parse leaves the server running, uses
the built-in, and emits `registry.build-warning` (BT-14, BT-19).
14. A template change is used for the next alarm, with no reload (BT-18).
15. A custom RSVP page containing `{{page_title}}` is served byte for byte, with
BT-21's headers. With `httpRsvpEnable` false, the page and the API are
both gone (BT-20, BT-21).
16. A tenant administrator can set its domain's logo, but can't change
`logoUrl`, any template, or its tenant's logo (BT-24, BT-25).
17. The built-in sign-in and RSVP pages show a URL logo through `/logo`'s
redirect, and their built-in logo when `/logo` is `404` (BT-26).
18. **(compat)** Every logo and template INBUXA holds reads back unchanged, and
each renders or is served as it did before cutover (BT-4, BT-19).
## Open questions / to observe
To check read-only against INBUXA's live Enterprise server before
implementation. None blocks the spec. Items 2 and 5 need a write, so they need
the operator's approval first, as with the other features' probes.
1. **What INBUXA holds.** Read `x:Enterprise.logoUrl`, every `x:Domain.logo`
and `x:Tenant.logo`, and the three template fields. If all are null, test 18
has nothing to carry over. Note the forms any logos take (URL, data URL,
bare base64), which BT-4 depends on.
2. **URL logos in email.** Does upstream embed a URL logo in alarm and invite
emails (which would mean it fetches it), reference it remotely, or skip it?
Look at the MIME structure of an invite already in a mailbox before
sending a new one. BT-7 and BT-9 stand either way. This only says how far
the fork differs.
3. **Upstream's `/logo`.** Its exact path and parameters, whether it honors
`domain=` and `Host`, and its status, content type and caching for data
URLs, URL logos and no logo. The fork's own pages already call
`/logo?domain=`. A plain `GET` settles this, and BT-5 is aligned to it where
nothing more important is at stake.
4. **Reload.** Does a change to a template or a logo take effect upstream
without a settings reload (BT-10, BT-18)?
5. **Invalid templates on write.** Does upstream refuse a template that
doesn't parse, or store it and fall back? It matters only for how much
invalid data INBUXA might already hold (BT-19).
6. **RSVP page headers.** What headers upstream sends with a custom RSVP page,
so BT-21 is known as a difference or not.
7. **The stored `fromName`.** Whether INBUXA's `x:CalendarAlarm` stores
`Stalwart Calendar` as a value or relies on the default. **Decision** until
known: stored values are never rewritten (SPEC.md §7). If it's stored, the
operator changes it once, by hand. (The packaged `schema.json.gz` carried
upstream's default, `Stalwart Calendar`, until 2026-09-18. It now says
`INBUXA Calendar`, matching the code.)
8. **INBUXA Admin's logo.** Upstream documents that its web interface picks its
logo by request hostname. Check in `inbuxa-admin` (an ordinary AGPL fork,
SPEC.md §5) whether it calls `/logo`, so it keeps working against BT-5.
+510
View File
@@ -0,0 +1,510 @@
# Feature spec: monitoring history, live tracing and alerts
Status: draft, 2026-09-18. Feature 6 in SPEC.md §4.
## Provenance
Written for the clean room (SPEC.md §3). Sources, and nothing else:
| Source | License | Used for |
|---|---|---|
| Stalwart's registry schema: `crates/registry/src/schema/*.rs` and `resources/schema/schema.json.gz` (objects, fields, defaults, enums, permissions, lists, forms, the `dashboards` and `layouts` sections), upstream `v0.16.22` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Object shapes, field meanings, defaults, what upstream flags as Enterprise, the admin views and dashboards the server has to feed |
| The AGPL telemetry code left after the strip: `crates/common/src/telemetry/`, `crates/common/src/config/telemetry.rs`, `crates/trc` (event, key and metric definitions, the collector, span tracking, the JSON serializer), `crates/http/src/api/mod.rs` and `diagnose.rs`, `crates/store` key layout, `crates/services/src/task_manager/`, `crates/common/src/auth/permissions.rs`, `crates/common/src/expr/`, `crates/common/src/manager/defaults.rs`, `crates/jmap/src/registry/` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | What already exists and must be kept: webhooks, exporters, the `metric()` expression function, span ids, storage subspaces, route and token names, default permissions |
| The integration suites `tests/src/telemetry/*.rs` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Tested behavior of alerts, metrics history, trace history and webhooks |
| The strip report, `docs/fork/strip-reports/v0.16.22.json` | This repository | Which files and snippets were removed, so which behavior is missing |
| Stalwart documentation (`website` repo): `docs/telemetry/{index,alerts,history,live,management,webhooks}.md`, `docs/0.15/telemetry/{alerts,live,history}.md`, `docs/ref/object/{trace,metric,log,alert,metrics-store}.md`, blog post "Announcing Dashboards" (0.9.3) | Unlicensed public documentation: facts used, prose not copied | Retention defaults, alert semantics, the live endpoints' parameters, which parts are Enterprise |
| ihasmail's admin dashboard (`web/src/lib/admin/adminDashboard.ts`, `adminAccess.ts`) | AGPL-3.0, ours | `x:Metric` query behavior as observed against INBUXA's live Enterprise server when ihasmail was built |
| RFC 8620, RFC 5322, RFC 3834 | IETF | JMAP semantics; the alert message format |
No Enterprise-only file or snippet was used. The drafting session never saw
Enterprise code. It writes specs only. Gaps are marked **Decision** or listed
under "Open questions / to observe", never filled from memory of upstream code.
## What it is
Three things an operator uses to see what the server is doing:
- **History.** The server keeps a record of every message delivery (a
*trace*: the inbound SMTP session or the outbound delivery attempt, with its
events) and a periodic sample of its metrics. Both are kept for a set
period, searchable, and drawn by the dashboards.
- **Live telemetry.** An administrator watches events and metrics as they
happen, filtered, in INBUXA Admin.
- **Alerts.** Rules over metrics that send an email, raise an event (which a
webhook can forward), or both, when a threshold is crossed.
Upstream ships these only in its Enterprise Edition. inbuxa-server ships them
to everybody. Webhooks, the log tracers, OpenTelemetry and Prometheus export
and the `x:Log` view are already AGPL and aren't part of this rebuild, except
where noted.
## Data model
Registry objects are unchanged from upstream, so INBUXA's existing settings
open as they are (SPEC.md §7). The upstream schema flags as Enterprise:
objects `x:Alert`, `x:MetricsStore`, `x:Trace`, `x:TracingStore`; fields
`x:DataRetention.holdMetricsFor`, `holdTracesFor`, `metricsCollectionInterval`,
`x:Search.indexTelemetry`, `indexTracingFields`. In inbuxa-server they're
ordinary.
### Settings
| Where | Field | Default | Meaning |
|---|---|---|---|
| `x:TracingStore` (singleton) | `@type` | schema `Disabled`; first boot inserts `Default` | `Disabled`: no trace history. `Default`: the data store. `FoundationDb`, `PostgreSql`, `MySql`: a store of its own |
| `x:MetricsStore` (singleton) | `@type` | as above | The same, for metric history |
| `x:DataRetention` | `holdTracesFor` | 30 days | Duration, nullable. How long a trace is kept |
| | `holdMetricsFor` | 90 days | Duration, nullable. How long a metric sample is kept |
| | `metricsCollectionInterval` | hourly, minute 0 | `x:Cron`: `Hourly`, `Daily` or `Weekly`. When metric history is sampled |
| `x:Search` | `indexTelemetry` | true | Whether traces are added to the search index |
| | `indexTracingFields` | `eventType`, `queueId`, `keywords` | Which trace fields are indexed |
| `x:Metrics` | `metrics`, `metricsPolicy` | all (`exclude`, empty list) | Which metrics are collected. Already AGPL, shared with the exporters |
Clean-up of expired history runs on the existing `dataCleanupSchedule`.
### The alert, `x:Alert`
| Field | Meaning |
|---|---|
| `enable` | Boolean, default true |
| `condition` | `x:Expression` (`match` list and `else`). The alert fires when it evaluates true |
| `eventAlert` | `Disabled`, or `Enabled` with `eventMessage` (text, nullable) |
| `emailAlert` | `Disabled`, or `Enabled` with `fromName` (nullable), `fromAddress`, `to` (a set of one or more addresses), `subject`, `body` |
### The trace, `x:Trace` (read-only)
`events`: a list of `x:TraceEvent`, each with `event` (an `EventType`),
`timestamp`, and `keyValues`: a list of `key` (a `Key`) and `value` (a
`TraceValue`: `String`, `UnsignedInt`, `Integer`, `Boolean`, `Float`,
`UTCDateTime`, `Duration`, `IpAddr`, `List`, `Event`, `Null`). Server-set:
`timestamp`, `from`, `to` (a string), `size`. Views: `x:Trace/InboundDelivery`
and `x:Trace/OutboundDelivery`.
### The metric sample, `x:Metric` (read-only)
Three variants by `@type`: `Counter` and `Gauge` (`metric`, `count`,
`timestamp`) and `Histogram` (`metric`, `count`, `sum`, `timestamp`).
`metric` is a `MetricType` (369 values at `v0.16.22`).
### The index task, `x:TaskIndexTrace`
`traceId`, `status`, `due`. Task type `IndexTrace`, permission
`taskIndexTrace`. Store maintenance type `reindexTelemetry` rebuilds the
trace index. Both already exist in AGPL code.
### Permissions
`sysAlert{Get,Query,Create,Update,Destroy}`, `sysTrace{Get,Query,Create,Update,Destroy}`,
`sysMetric{Get,Query,Create,Update,Destroy}`, `sysTracingStore{Get,Update}`,
`sysMetricsStore{Get,Update}`, `sysDataRetention{Get,Update}`, `liveTracing`,
`liveMetrics`, `taskIndexTrace`. The AGPL default-permission table gives all
of these to the superuser set only: not to tenant administrators and not to
users (`crates/common/src/auth/permissions.rs`).
### Storage
The AGPL store layout is kept: traces in subspace `o`
(`SUBSPACE_TELEMETRY_SPAN`), metric samples in subspace `x`
(`SUBSPACE_TELEMETRY_METRIC`), each keyed by a big-endian `u64` id. A trace's
id is its span id, which the server already assigns from its snowflake
generator (milliseconds since an epoch in the high bits), so key order is time
order. The trace search index is `SearchIndex::Tracing`, with fields
`EventType`, `QueueId` and `Keywords`.
**Existing data at cutover, Decision.** The *value* encoding of stored traces
and samples lived in files the strip removed (`telemetry/tracers/store.rs`,
`telemetry/metrics/store.rs`, `trc/src/serializers/binary.rs`), so this spec
doesn't know it and doesn't try to match it. inbuxa-server writes its own
encoding, which starts with a format byte of its own. Records it can't decode
are skipped, never an error, and are removed by the normal age purge (MON-17),
so INBUXA's old history ages out within 30 and 90 days. At cutover, run
`reindexTelemetry` once so the search index holds only readable traces.
Settings, alerts and every other registry object open unchanged.
## Required behavior
### Switches and defaults
- **MON-1.** History is on when its store isn't `Disabled`. The fork's first
boot inserts `TracingStore::Default` and `MetricsStore::Default` when none
exists (`manager/defaults.rs`, already in place), so a new install records
trace and metric history in its data store from the start, kept 30 and 90
days. An upgraded install keeps whatever it has.
- **MON-2.** A store that can't be opened (a separate PostgreSQL that's down,
or a backend not compiled in) turns that history off with a build warning,
as the AGPL store builder already does. Startup continues and mail flows.
- **MON-3.** A change to a store setting takes effect on the next settings
reload, with no restart, like every other setting. **Decision**, in line
with undelete UD-6a: a change to the `DataRetention` fields in this spec or
to an `x:Alert` takes effect without a reload, since an operator who lowers
retention or disables a noisy alert expects it at once.
### Metric history
- **MON-4.** At each `metricsCollectionInterval` tick, every node writes one
sample per metric selected by `x:Metrics.metrics` and `metricsPolicy`:
- **Counter:** `count` is the increase since the node's previous sample.
Counters in memory are totals since the process started, so the node keeps
the last totals it wrote. The first sample after a start counts from the
start. A counter that didn't move writes nothing.
- **Gauge:** `count` is the reading at the tick.
- **Histogram:** `count` and `sum` are the increases since the previous
sample. A histogram that saw nothing writes nothing.
All samples of one tick share its `timestamp`. That's the shape ihasmail
already reads from INBUXA: a counter holds what happened in the interval, a
gauge the reading at its end.
- **MON-5.** Gauges are always written, even when unchanged, so a window with
no samples at all means history is off, not a quiet period. ihasmail relies
on this.
- **MON-6.** There's one edition, so every gauge and histogram the collector
has is collected, stored and exported: `server.memory`, `queue.count`,
`user.count`, `domain.count`, the active-connection gauges, and all eleven
histograms, including ingest, index, store read and write, and DNS lookup
times. The `is_enterprise` arguments in `trc` and `common::telemetry` go.
Prometheus and OpenTelemetry exports gain the same metrics.
- **MON-7.** `queue.count` is set from the queue itself on the existing
five-minute metrics calculation, not only moved by queue events, so it's
right after a restart. **Decision**: the in-memory gauge starts at zero on
restart, and nothing in the AGPL code corrects it.
- **MON-8.** In a cluster, samples carry no node field (the schema has none).
Each node writes its own values under the same timestamp, and dashboards
aggregate them with the `sum` or `avg` their cards name. A one-node install
is unaffected.
- **MON-9.** After writing a tick's samples the node emits
`telemetry.metrics-stored`.
### Trace history
- **MON-10.** A trace is stored for each **inbound SMTP session** (span opened
by `smtp.connection-start`) and each **delivery attempt** (span opened by
`delivery.attempt-start`, which includes local delivery). Other spans
(IMAP, POP3, HTTP, ManageSieve) aren't stored. The upstream suite confirms
this: one LMTP delivery, made while the admin was busy over HTTP, left
exactly two traces.
- **MON-11.** **Decision**: an inbound session in which no `MAIL FROM` was
accepted or refused (a probe, a scanner, a banned address dropped at
connect) isn't stored. These are most of a public server's connections and
none is a message delivery. The live view and the log still show them.
- **MON-12.** A trace holds the span's events, from the opening event to the
closing one, that are at `info` level or above after `x:EventTracingLevel`
overrides. **Decision** on the level. It never holds raw I/O events
(`*.raw-input`, `*.raw-output`, milter read and write). Those carry message
content and authentication exchanges.
- **MON-13.** The trace is written once, when the span closes. A span still
open after one day is dropped, matching the collector's own `SPAN_MAX_HOLD`.
- **MON-14.** Server-set fields: `timestamp` is the opening event's time;
`from` is the first `from` value in the trace; `to` is every distinct `to`
value, comma-separated; `size` is the message size from the trace's events,
or 0. **Decision** on the derivation. Compare with upstream (to observe, 3).
- **MON-15.** Bounds per trace. **Decision**: at most 1,000 events, then one
final marker event noting how many were cut; string values over 4 KiB are
truncated. A trace is diagnostic, not an archive.
### Search
- **MON-16.** With `indexTelemetry` on, storing a trace schedules an
`IndexTrace` task. The task builds one document for `SearchIndex::Tracing`
with the fields named in `indexTracingFields`:
- `eventType`: every event type in the trace;
- `queueId`: every `queueId` value;
- `keywords`: every address in `from` and `to`, each address's domain, every
`domain`, `hostname`, `remoteIp`, `messageId` and `accountName` value.
So searching `example.org` finds every trace to or from that domain, as the
upstream suite expects. With `indexTelemetry` off nothing is indexed, and
the `text` and `queueId` filters are refused (see "Interfaces").
### Retention and growth
- **MON-17.** On each `dataCleanupSchedule` run, traces older than
`holdTracesFor` and samples older than `holdMetricsFor` are deleted, with
their search-index documents. Keys are time-ordered, so this is a range
delete. A record past its deadline is never returned, even before clean-up
has run.
- **MON-18.** A null `holdTracesFor` or `holdMetricsFor` means no age limit.
**Decision**: that's what "unset" means for the other retention durations
except the archive ones. INBUXA Admin and ihasmail show a warning next to a
null value. Confirm upstream's meaning (to observe, 7).
- **MON-19.** Growth is bounded by settings, not by a byte cap:
- Metrics: the smallest interval is hourly, so at most 24 ticks a day, each
at most one sample per selected metric per node. At the defaults that's
at most 2,160 ticks over 90 days, and in practice a few hundred samples a
tick.
- Traces: one per delivered or attempted message, at most 1,000 events each
(MON-15), kept 30 days. Connections that send nothing aren't stored
(MON-11).
The storage dashboard reports the size of both subspaces, so an operator
sees what history costs.
### Live telemetry
- **MON-20.** **Live tracing.** `GET /api/live/tracing` returns a
`text/event-stream`. Each frame is `event: event` and `data:` a JSON array
of events, in the format webhooks already send (`id`, `createdAt`, `type`,
`data`), the same framing the AGPL delivery tester uses. Query parameters:
`filter` matches a value in any key; any `Key` name (for example
`remoteIp`, `domain`, `queueId`) matches that key only; several combine with
AND. **Decision**: keys are given in their camel-case `Key` names; the
hyphenated names in upstream's docs (`remote-ip`) are accepted too.
- **MON-21.** The live tracing stream never carries raw I/O events
(MON-12). **Decision**: raw protocol lines include credentials, and anyone
who needs them can set a file tracer at `trace` level on the host.
- **MON-22.** **Live metrics.** `GET /api/live/metrics` returns a
`text/event-stream` of the current values of the metrics listed in
`metrics` (comma-separated names; all selected metrics when absent), every
`interval` seconds (default 30, minimum 1). Each frame's data is a JSON
array of `{"id", "type", "value"}` for counters and gauges, and `{"id",
"type", "count", "sum"}` for histograms. **Decision** on the frame shape;
check it against INBUXA Admin (to observe, 9).
- **MON-23.** **Tokens.** Browsers can't put headers on an event stream, so
as with the delivery tester, `GET /api/token/tracing` and
`/api/token/metrics` return a single-use token, valid 60 seconds, bound to
the account and to grant type `live_tracing` or `live_metrics` (both already
defined). The stream accepts it as `?token=`, or a normal `Authorization`
header. Issuing the token needs `liveTracing` or `liveMetrics`, and on this
fork a token with the `inbuxa:admin` scope (contract.md C-18).
- **MON-24.** A live subscriber is lossy: a slow client loses events, never
slows the server. At most 8 live streams run at once per node, and each ends
after 30 minutes, when the client reconnects with a fresh token.
**Decision** on both numbers. The time limit means a revoked grant
(contract.md C-12) or a removed permission stops a stream within 30 minutes.
### Alerts
- **MON-25.** **Evaluation.** Each enabled alert's condition is evaluated
every 60 seconds (**Decision**) on every node that holds the
`metricsCalculate` task role, against that node's current values. A
one-node install always holds it. The condition language is the server's
expression language, and a metric is read two ways, both accepted:
- `metric('queue.count')`, which the AGPL expression code already supports;
- the name with dots and hyphens as underscores (`queue_count`), as
upstream's docs describe.
Counter values in a condition are totals since the process started, as
`metric()` already reads them. Gauges are the current reading, histograms
their average.
- **MON-26.** **Firing.** An alert fires when its condition goes from false to
true, including the first evaluation after start. While it stays true it
doesn't fire again. Once false, it can fire again. **Decision**: upstream's
docs say email goes out "each time the condition becomes true". State is in
memory, so a restart while the condition holds fires once more. A condition
that fails to evaluate (a metric name that doesn't exist, a type error) is
rejected when the alert is saved, and never fires.
- **MON-27.** **Placeholders.** In `eventMessage`, `subject` and `body`,
`%{metric.name}%` (the dotted name) is replaced by the value used in that
evaluation. Whole numbers print without decimals ("3", not "3.0"); others
with at most two. An unknown name is left as written.
- **MON-28.** **Event notification.** With `eventAlert` enabled, firing emits
`telemetry.alert-event` (level `warn`), with `details` set to the rendered
message and the alert's id. Webhooks subscribed to that event forward it.
- **MON-29.** **Email notification.** With `emailAlert` enabled, firing queues
one message to every address in `to`, through the normal outbound queue, so
it's retried, DKIM-signed for a local sender domain, and visible in the
queue like any other. Headers: `From: "fromName" <fromAddress>` (bare
address when `fromName` is null), `To`, `Subject`, `Date`, `Message-ID`,
and `Auto-Submitted: auto-generated` (RFC 3834). Body: `text/plain`, UTF-8.
The server emits `telemetry.alert-message` once it's queued.
- **MON-30.** An alert email is sent even when the queue itself is the
problem the alert reports. It's queued like any message. If queueing fails,
the event notification (MON-28) still happens and the failure is logged.
### Who may see what
- **MON-31.** Traces, metric samples, alerts, the two store settings and live
telemetry are server-level. By default only the superuser permission set
holds their permissions (see "Permissions"). A tenant administrator gets
`forbidden` for all of them, even if a role grants the permission, unless
its tenant allows it (multi-tenancy MT-13). **Decision**: traces carry other
tenants' addresses and IP addresses, and metrics describe the whole server.
A tenant-scoped trace view is a possible later addition (open question 11).
- **MON-32.** `x:Trace` and `x:Metric` can't be created or updated, as the
AGPL registry already enforces. **Decision**, an addition: a trace can be
destroyed with `sysTraceDestroy`, so an operator can honor a request to
erase someone's delivery records before they age out. Samples can't be
destroyed one by one.
- **MON-33.** Traces are personal data: they hold addresses, IP addresses,
host names and message ids, never message content (MON-12). Retention
(MON-17) is the main control, and INBUXA Admin's retention form says what's
kept. Nothing in this feature sends traces off the host. Webhooks and
OpenTelemetry send only what an operator configures.
### Failure behavior: telemetry never blocks mail
- **MON-34.** Trace and sample writes happen off the mail path, through a
lossy collector subscriber with a bounded buffer. When the buffer is full,
events are dropped and counted, and one error event is logged per minute at
most. No SMTP, IMAP, JMAP or delivery step ever waits on history.
- **MON-35.** A failing tracing or metrics store (full, unreachable, slow)
loses history and logs, rate-limited. It never fails a delivery, a login or
startup.
- **MON-36.** A failed `IndexTrace` task is retried by the task manager as
now. The trace stays readable by id and by date meanwhile.
- **MON-37.** A failed alert evaluation or notification is logged and retried
on the next evaluation. It never stops the other alerts.
- **MON-38.** A failed clean-up leaves the records for the next run. Expired
records are still hidden (MON-17).
### Edition cleanup
- **MON-39.** Remove the `is_enterprise` and `_is_enterprise` parameters and
their `inbuxa:` signposts in `telemetry/mod.rs` and `config/telemetry.rs`;
`Tracers::parse` uses `storage` again. Drop `Metric` and `Trace` from
`assert_enterprise_object`. Replace the "Enterprise feature" stubs for
`/api/token/{tracing,metrics}` and `/api/live/{tracing,metrics}`, and the
`cfg(not(feature = "enterprise"))` branch in `management_access_token`.
## Interfaces
- **JMAP, existing names, unchanged.** Over `urn:stalwart:jmap`:
- `x:Alert/get`, `/query`, `/set`, and the `x:TracingStore`,
`x:MetricsStore`, `x:DataRetention`, `x:Search` singletons.
- `x:Trace/get` and `/query`. Filters: `text`, `timestamp` (comparison names
such as `timestampIsGreaterThan`), `queueId`, `event` (the type of the
trace's opening event: the list views use `smtp.connection-start` and
`delivery.attempt-start`). Sort by `timestamp`, newest first by default.
`/set` refuses create and update, and allows destroy (MON-32).
- `x:Metric/get` and `/query`. Filters: `metric` (one name or a list) and
the `timestamp` comparisons `timestampIsGreaterThan`,
`timestampIsGreaterThanOrEqual`, `timestampIsLessThan`,
`timestampIsLessThanOrEqual`. A bare `timestamp` filter is
`unsupportedFilter`, as upstream. Sort by `timestamp` either way, with
`position`, `anchor` and `anchorOffset` paging and `calculateTotal`.
- `x:Trace` text or `queueId` filters when `indexTelemetry` is off:
`unsupportedFilter`, with a description saying trace search is off.
- **HTTP.** `/api/token/tracing`, `/api/token/metrics`, `/api/live/tracing`,
`/api/live/metrics` as MON-20 to MON-24. These are the route names in the
AGPL code. Upstream's docs name `/api/telemetry/traces/live` and
`/api/telemetry/metrics/live`. **Decision**: serve those as aliases too.
- **Webhooks, unchanged.** Already AGPL, and intact after the strip: the
webhook tracer is in neither the removed-file list nor the snippet list,
and upstream's docs don't mark webhooks as Enterprise. `POST` of
`{"events": [...]}`, HMAC-SHA256 in `X-Signature`, batched by `throttle`,
stale events dropped after `discardAfter`.
- **Dashboards.** The schema's six dashboards (Overview, Network, Security,
Delivery, Performance, Storage) name, per card, `live` (MON-22) or
`history` (`x:Metric`) and an aggregate. The server serves what they name.
INBUXA Admin draws them unchanged.
## ihasmail changes
These go in ihasmail-inbuxa, not public ihasmail, which stays Stalwart-facing
(SPEC.md §5).
- The dashboard already reads `x:Metric` for received, sent and memory. Keep
it. Drop the Enterprise wording from its comments and code paths, and the
"refused as `forbidden`" branch becomes a plain error.
- Change the footer line that sends people to "Stalwart's own
administration" to name INBUXA Admin, with its link.
- Warn on the dashboard when metric history is off (no samples in the window,
MON-5), with a link to INBUXA Admin's Metrics Store page.
- Live tracing, trace history and alerts stay in INBUXA Admin (SPEC.md §5.4).
ihasmail doesn't grow screens for them.
- contract.md C-19 has to list `x:Metric` (get and query) among the object
types the `inbuxa:account-admin` scope reaches, or the dashboard loses its
message cards.
- Translation work: two changed strings (the footer line and the error that
replaces the refused case) and one new one (the history-off warning), each
in ihasmail's nine languages.
## Acceptance tests
Every test runs against inbuxa-server built with no Enterprise code.
1. New install: `TracingStore` and `MetricsStore` read `Default`, retention
30 and 90 days, hourly collection (MON-1).
2. Tracing store set to an unreachable PostgreSQL: the server starts, and
mail is delivered (MON-2, MON-35).
3. Two collection ticks with traffic between: counters hold the increase,
gauges the reading, idle counters write nothing (MON-4, MON-5).
4. `queue.count` is right after a restart with mail queued (MON-7).
5. Prometheus output includes `queue_count` and the store and DNS histograms
(MON-6).
6. One LMTP delivery, with HTTP traffic alongside: exactly two traces, one
`smtp.connection-start`, one `delivery.attempt-start` (MON-10).
7. An SMTP connection that quits without `MAIL FROM`: no trace (MON-11).
8. A trace holds no raw I/O event, and nothing below `info` (MON-12).
9. `from`, `to`, `size` and `timestamp` set as MON-14.
10. Text search for the sender, the recipient and their domain each finds both
traces (MON-16).
11. `indexTelemetry` off: `text` filter is `unsupportedFilter`, `timestamp`
still works (MON-16, Interfaces).
12. Purge with retention 1 second: all traces and their index entries gone.
With 2 seconds, nothing gone yet (MON-17).
13. Metric query with `timestampIsGreaterThan`, and paging forward, backward
and by anchor, returns consistent pages (Interfaces).
14. Live tracing with `?remoteIp=` shows only that client's events, and no
raw I/O (MON-20, MON-21).
15. Live metrics with `metrics=server.memory&interval=1` yields a frame a
second (MON-22).
16. Live token: expires after 60 seconds, works once, refused without
`liveTracing` (MON-23).
17. A ninth live stream is refused. A stream closes after 30 minutes
(MON-24).
18. Alert on `metric('domain.count') > 1 && metric('cluster.publisher-error') > 3`
with both conditions met: one email with the placeholders filled ("3
domains and 5 cluster errors"), `From: "Alert Subsystem"
<[email protected]>`, one `telemetry.alert-event`. The opposite condition
fires nothing (MON-25 to MON-29).
19. The same alert with an underscore condition (`domain_count > 1`) fires
the same way (MON-25).
20. The alert fires once while the condition stays true, and again after it
has been false (MON-26).
21. An alert with an unknown metric name is refused on save (MON-26).
22. A webhook subscribed to `telemetry.alert-event` receives the alert
(MON-28).
23. A tenant administrator with `sysTraceGet` in a role, in a tenant that
doesn't allow it: `forbidden` (MON-31).
24. `x:Trace/set` destroy by a superuser removes the trace and its index
entry. Create and update are refused (MON-32).
25. A full tracing store buffer drops events without delaying an SMTP session
(MON-34).
26. **(compat)** A copy of INBUXA's data opens: alerts, store settings and
retention read back unchanged. Old traces and samples it can't decode are
skipped, and are gone after one purge past their age (Data model).
### The gated integration suites
`tests/src/telemetry/mod.rs` gates four suites behind `pending-rebuild`:
| Suite | Needs rebuilding | Why it's gated |
|---|---|---|
| `alerts.rs` | Yes | It calls `process_alerts()`, which lived in the removed `enterprise/alerts.rs`. The rebuild provides a function of that shape (returns the messages it would send) so the suite runs unchanged |
| `metrics.rs` | Yes | It needs the metrics store's `purge_metrics` and the test-data generator `insert_test_metrics`, from the removed `metrics/store.rs` and `metrics/test_data.rs` (the latter is the dangling `test_data` module in `metrics/mod.rs`) |
| `tracing.rs` | Yes | It needs the tracing store's `purge_spans`, from the removed `tracers/store.rs` |
| `webhooks.rs` | No | Webhooks are AGPL and intact. The suite is gated only because its clean-up calls `purge_spans` through the shared harness. Dropping or guarding that one call lets it run now, before the rebuild |
Recommended: un-gate `webhooks.rs` straight away (a separate change, not made
here), so webhooks are tested while the rest is rebuilt.
## Open questions / to observe
To check read-only against INBUXA's live Enterprise server later. None of
these needs a write.
1. How many traces a day INBUXA stores against its SMTP connection count:
whether upstream stores connection-only sessions (MON-11).
2. Which events and levels a stored trace holds, and whether raw I/O ever
appears (MON-12, MON-15).
3. How upstream fills `from`, `to` and `size` on a trace with several
recipients (MON-14).
4. Whether INBUXA's samples include `Histogram` records, which gauges appear
every tick, and whether counter samples are per-interval increases
(MON-4 to MON-6). ihasmail's code says they are.
5. Whether `queue.count` in INBUXA's history matches the real queue after a
restart (MON-7).
6. INBUXA's current `holdTracesFor`, `holdMetricsFor`,
`metricsCollectionInterval`, `indexTelemetry` and store settings, so the
cutover keeps them.
7. What a null `holdTracesFor` or `holdMetricsFor` does upstream: keep
forever, or store nothing (MON-18).
8. The `x:Trace/query` filters upstream accepts beyond `text`, `timestamp`,
`queueId` and `event`, and the `x:Metric/query` comparison names.
9. The live endpoints: the token response body, the SSE frame shape for
tracing and metrics, keep-alive comments, and which path INBUXA Admin
calls (MON-20 to MON-23). A `GET` of each is read-only.
10. Whether INBUXA has any `x:Alert` objects, and whether their conditions use
`metric()` or underscore names (MON-25). Whether INBUXA's logs show
`telemetry.alert-event` repeating while a condition held, which settles
the cadence and repeat behavior (MON-25, MON-26).
11. Whether upstream's tenant `Admin` role holds any `sysTrace*`,
`sysMetric*` or live permission, and whether a tenant view of its own
domains' traces is wanted (MON-31).
12. Size of INBUXA's `o` and `x` subspaces, to check MON-19's estimate.
+3 -1
View File
@@ -224,7 +224,9 @@ Each requirement has an ID, and tests name the IDs they check.
- **MT-22.** A signed-in principal can read the logo that applies to it: its
domain's `logo` if set, else its tenant's `logo` if set, else none.
**Decision** on the order: a domain is more specific than a tenant. Exposed
over JMAP so ihasmail can draw it (see "Interfaces").
over JMAP so ihasmail can draw it (see "Interfaces"). Branding BT-1 and BT-2
(`branding-and-templates.md`) extend the chain past the tenant, to the
server-wide logo and then the built-in one, and are the full rule.
- **MT-23.** The server never fetches a logo URL itself. ihasmail draws URL
logos through its image proxy, as it does today.
@@ -0,0 +1,536 @@
# Feature spec: per-domain directories and the OIDC directory
Status: draft, 2026-09-18. Feature 9, proposed for SPEC.md §4. It isn't in
§4's table yet. Proposed row:
| # | Feature | What an operator gets | Notes |
|---|---|---|---|
| 9 | Per-domain directories | Each domain can sign its users in against its own LDAP, SQL or OIDC directory, instead of the server's one default | The OIDC directory itself is already AGPL and isn't a gap (see below). Tenants bring their own directories. |
## Provenance
Written for the clean room (SPEC.md §3). Sources, and nothing else:
| Source | License | Used for |
|---|---|---|
| This repository: `crates/directory`, `crates/common/src/auth`, `crates/common/src/cache`, `crates/common/src/network/mta.rs`, `crates/http/src/auth`, `crates/jmap/src/registry/mapping`, `tests/src/directory`, `tests/src/utils` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL (Enterprise-only code was stripped before commit) | How directories are opened, chosen, queried and cached today; what the OIDC directory validates; what survives the strip |
| Registry schema: `crates/registry/src/schema/*.rs` and `resources/schema/schema.json.gz` | As above | Field shapes, defaults, descriptions, and which fields upstream flags `"enterprise": true` |
| Strip report `docs/fork/strip-reports/v0.16.22.json` (and `.md`) | Ours | Which files and how many snippets were removed, per file. Only names and counts were read |
| Stalwart documentation (`website` repo): `auth/backend/oidc.md`, `auth/backend/index.md`, `install/directory.md`, `auth/scim/provisioning.md`, `auth/authorization/tenants.md`, `server/enterprise.md`, `ref/object/domain.md`, `ref/object/directory.md`, 0.15 `auth/backend/oidc.md` and `auth/principals/domain.md`, and the edition comparison `pages/compare.yml` | Unlicensed public documentation: facts used, prose not copied | What upstream says is Enterprise, just-in-time provisioning rules, OIDC limits |
| OpenID Connect Core 1.0 and Discovery 1.0; RFC 7662; RFC 9068; RFC 7628; RFC 4511; RFC 8620 | IETF / OpenID Foundation | Token validation, introspection, `OAUTHBEARER`, LDAP, JMAP errors |
No Enterprise-only file or snippet was used. The author is a fresh session
that has never seen Enterprise code, and read the stripped tree only. No
server, live or local, was probed for this spec. Where the sources above
don't settle a behavior, this spec makes a **Decision** of its own, or lists
it under "Open questions / to observe". Nothing is filled in by guessing what
upstream code does.
## What it is
A directory is where the server checks who someone is: the internal
directory (accounts in the server's own store), or an external one (an LDAP
server, an SQL database, or an OpenID Connect provider). Today one setting,
`x:Authentication.directoryId`, picks a single external directory for the
whole server, or none for the internal one.
Per-domain directories let each domain pick its own. One server can sign in
`corp.example` users against the company's Active Directory, `school.example`
users against a Keycloak realm, and `example.net` users against the internal
directory. This matters most with tenants (Feature 1): each organization
brings its own identity system.
Upstream ships per-domain directories only in its Enterprise Edition. The
edition comparison lists "Per-domain directory backends" as Enterprise-only.
inbuxa-server ships it to everybody.
### Where the fork stands today
`x:Domain.directoryId` is stored, writable over JMAP, validated as a
reference to an `x:Directory`, and copied into the domain cache
(`DomainCache.id_directory`, `crates/common/src/cache/principals.rs`). The
cache is already invalidated when it changes (`cache/invalidate.rs`). But
nothing reads it. The two lookups every caller goes through,
`get_directory_for_domain` and `get_directory_for_cached_domain`
(`crates/common/src/auth/authentication.rs`, marked `inbuxa:`), return the
server default for every domain.
So a domain carried over from an Enterprise install with `directoryId` set
currently signs in against the wrong source: the server default, or the
internal directory. **Decision:** INBUXA's cutover (SPEC.md §7) checks the
copied data for any domain with `directoryId` set. If there is one, this
feature is a cutover blocker.
Everything else the feature needs is already in the AGPL tree, and already
routes through those two lookups: sign-in, bearer-token routing, recipient
lookup, account discovery, the PACC DNS record, and the refusal to change
passwords on external accounts. Rebuilding the feature mostly means making
the two lookups honor the domain, then adding the rules below.
## Is the OIDC directory a gap?
**No.** Signing in against an OIDC provider is AGPL in upstream, and works in
the stripped tree as the server's default directory. Only two things around
it are Enterprise: pointing a single domain at it (this feature), and giving
it a tenant (`OidcDirectory.memberTenantId`, Feature 1). The integration test
is gated only because the test file itself was Enterprise-only and was
removed.
Evidence:
1. **Headers.** `crates/directory/src/backend/oidc/mod.rs`, `config.rs` and
`lookup.rs` all carry `AGPL-3.0-only OR LicenseRef-SEL`. So do
`core/dispatch.rs`, `core/config.rs`, `core/sasl.rs` and `lib.rs`.
2. **Nothing stripped from the directory crate.** The strip report lists no
removed file and no removed snippet anywhere under `crates/directory`. Its
`enterprise` Cargo feature is declared in `Cargo.toml` and gates no code.
3. **Schema flags.** The `x:Directory` object and its `Oidc` variant aren't
flagged. Of `x:OidcDirectory`'s nine fields only `memberTenantId` is
flagged, the same as on the LDAP and SQL variants.
`x:Authentication.directoryId`, which picks the default directory, isn't
flagged. `x:Domain.directoryId` is.
4. **It compiles and is wired in.** The stripped build (SPEC.md §2.2b) opens
OIDC directories in `Directories::build` and uses one as the default when
`Authentication.directoryId` names it. The bearer path in
`authentication.rs` (routing, then the external directory, then the
server's own tokens) sits outside any removed snippet. So do
`/api/discover` and the PACC record.
5. **Ungated AGPL tests use it.** `tests/src/directory/discovery.rs` runs a
Keycloak container and asserts that the default directory is the OIDC
provider. `tests/src/directory/unavailable.rs` checks an unreachable OIDC
directory. Neither is behind `pending-rebuild`.
6. **What's gated, and why.** `tests/src/directory/oidc.rs` was a whole file
licensed `LicenseRef-SEL` alone. It was removed, which left
`pub mod oidc;` dangling (strip report, "dangling mods") and the
`oidc::test()` call gated. What it tested can't be known without reading
it, and it wasn't read. Nothing shows OIDC sign-in itself to be
Enterprise.
7. **Public docs.** The OIDC backend page has no Enterprise marker. The
comparison page lists "Third-party OIDC providers" and "OpenID Connect" in
both editions, and "Per-domain directory backends" as Enterprise-only. The
reference page for `Domain.directoryId` marks it Enterprise.
What follows from this: the OIDC directory needs no rebuild. It does need a
new integration test, written from this spec (tests 12 to 17 below), to
replace the lost one. The `oidc::test()` gate comes off when that lands.
## Data model
Unchanged from upstream, so existing data opens as it is (SPEC.md §7).
### On `x:Domain`
| Field | Type | Meaning |
|---|---|---|
| `directoryId` | `Id<x:Directory>`, nullable, mutable | The directory this domain's accounts sign in against. Flagged Enterprise upstream; ordinary here |
The schema describes null as "use the internal directory". This spec reads
null as "use the server default" instead (DIR-1), because that's what every
domain does today, in both editions, whenever a default is set.
### On `x:Authentication` (singleton)
| Field | Type | Meaning |
|---|---|---|
| `directoryId` | `Id<x:Directory>`, nullable | The server default directory. Null: the internal directory |
### `x:Directory`, three variants
Permission prefix `sysDirectory`. The variants are `Ldap`
(`x:LdapDirectory`), `Sql` (`x:SqlDirectory`) and `Oidc` (`x:OidcDirectory`).
Each variant carries `memberTenantId`, a nullable `Id<x:Tenant>` flagged
Enterprise upstream. Feature 1 covers that field. `TenantStorageQuota` has
`maxDirectories`.
### `x:OidcDirectory`
| Field | Type, default | Meaning |
|---|---|---|
| `description` | string | Label |
| `issuerUrl` | URI | The provider's issuer. Discovery is fetched from `<issuerUrl>/.well-known/openid-configuration` |
| `requireAudience` | string, nullable | If set, a token's `aud` must include it |
| `requireScopes` | set of strings, default `openid`, `email` | Every listed scope must be in the token |
| `claimUsername` | string, default `preferred_username` | Claim that names the account |
| `usernameDomain` | string, nullable | Appended as `@domain` when the username claim has no `@`. Also appended to bare group names |
| `claimName` | string, nullable, default `name` | Claim for the display name |
| `claimGroups` | string, nullable | Claim for group memberships |
| `memberTenantId` | `Id<x:Tenant>`, nullable | Tenant (Feature 1) |
The LDAP and SQL variants are unchanged, and this feature adds nothing to
them.
## Required behavior
Each requirement has an ID, and tests name the IDs they check. "Effective
directory" means the directory DIR-1 picks for a domain.
### Choosing the directory
- **DIR-1.** A domain's effective directory is the one its `directoryId`
names. If that is null, it's the server default
(`Authentication.directoryId`). If that is null too, it's the internal
directory. **Decision:** null means the server default, not the internal
directory as the schema text says. Every existing install with a default
external directory relies on this reading, since that is how every domain
behaves before this feature exists.
- **DIR-2.** Which domain decides:
- **Password sign-in:** the domain of the address signing in. With
impersonation (`target%master`), it's the master user's domain. A bare
name gets the default domain, as now.
- **Bearer token:** the domain of the user the client names (the SASL
`a=` authorization identity, or the HTTP username). If there is none, the
domain of the first address found in the token's claims (`email`,
`preferred_username`, `upn`, as the code reads them now). If there is
none of those either, the server default. The claims are read unverified,
and only to choose the directory. The chosen directory then verifies the
token (DIR-26), and DIR-6 checks the result.
- **Recipient:** the recipient's domain.
- **DIR-3.** Local credentials are checked before any directory, as now:
the recovery admin, app passwords and API keys. They belong to the server,
not the directory, and they're how users of clients without `OAUTHBEARER`
reach an OIDC domain (public OIDC docs). See open question 3.
- **DIR-4.** No fallback. A domain whose effective directory is external
signs in against that directory and nothing else. When it says no, is
unreachable, or failed to start, sign-in fails. It never falls back to the
server default, another directory, or a password held in the internal
directory. **Decision** (settled for this spec). The AGPL test
`unavailable.rs` already requires this for password sign-in against an
unavailable directory.
- **DIR-5.** A `directoryId` that names no directory the server could build
(deleted, or never opened) makes the domain's directory unavailable: DIR-4
applies. It is never read as "no directory". Today the cached-domain lookup
returns nothing in that case, which would mean internal sign-in. It must
not.
- **DIR-6.** A directory speaks only for its own domains. The account a
directory returns must be on a domain whose effective directory is that same
directory. Otherwise sign-in fails, and no account is created or updated.
**Decision.** Without it, a tenant's identity provider could sign someone in
as, or create, an account on another tenant's domain. The same rule filters
what synchronization accepts. An alias or group address on a domain served
by a different directory is dropped, and a warning event is emitted.
Upstream's code already drops aliases on other tenants' domains. This goes
further.
- **DIR-7.** The token must match the named user. When a bearer client
names a user (DIR-2) and the token resolves to a different account, sign-in
fails, unless the named address is one of the account's aliases and the
account holds `authenticateWithAlias`, the same rule as password sign-in.
**Decision.** Today the bearer path doesn't compare them.
- **DIR-8.** Tokens the server issued itself (its own OAuth provider) are
still accepted after the external directory rejects a bearer token, as now.
They aren't another directory. For a domain with its own directory, the
server's sign-in page authenticates through that directory (DIR-4), so the
server only issues such tokens on its say-so.
### Recipients
- **DIR-9.** Recipient lookup asks the domain's effective directory when it
can look recipients up (LDAP, SQL). A positive answer synchronizes the
account or group (DIR-14) before the message is accepted. The directory is
the authority for accounts on that domain: an account in the internal store
that the directory doesn't know isn't a valid recipient. Mailing lists and
the catch-all still resolve from the internal store. That is what the AGPL
code does today for the server default, applied per domain.
- **DIR-10.** An OIDC domain has no recipient lookup, because OIDC offers
none. The internal store decides. An account that has never signed in
doesn't exist unless an administrator created it first (public docs).
Administrators may create accounts on an OIDC domain, without a password
(DIR-13).
- **DIR-11.** If the directory can't be reached during a recipient lookup, the
answer is a temporary failure (`4xx`). The message is never accepted, and
the lookup never falls back to the internal store. **Decision**, to be
checked by test 9.
### Discovery
- **DIR-12.** `GET /api/discover/{address}` returns the discovery document
of the address's domain's provider when its effective directory is OIDC.
Otherwise it returns the server's own OAuth metadata. The recovery admin
always gets the server's own. The PACC DNS record for a domain carries the
domain's provider issuer when its effective directory is OIDC, and the
server's own URL otherwise. Both work this way today, but only for the
server default.
### External accounts
- **DIR-13.** On an account whose domain's effective directory is external,
setting or changing the password or its OTP secret is refused with
`forbidden`, and `/api/account` leaves out `sysAccountPasswordGet` and
`sysAccountPasswordUpdate`. That's today's behavior, decided per domain.
App passwords and API keys stay allowed.
### Creating and updating accounts (just-in-time)
- **DIR-14.** After a successful sign-in, or a positive recipient answer, the
server synchronizes the directory's record into a local account, matched by
address (local part and domain):
- **Missing account:** created with the local part as name, the domain,
the domain's tenant (MT-7), the `User` roles, the description, and the
aliases and groups (as filtered by DIR-6). It also gets a password
credential when the directory supplies a secret (LDAP, SQL; never OIDC).
Missing groups are created on the same terms.
- **Existing account:** the description is overwritten when the directory
supplies a different one. Aliases are added and never removed. Groups are
replaced when the directory reports them: a missing claim leaves them
alone, while an empty list clears them. The secret is updated. Quotas,
roles, permissions and settings are local and never touched.
- The domain must already exist and be enabled. A directory never creates a
domain.
- **DIR-15.** Creating an account or group this way counts against the
tenant's `maxAccounts` and `maxGroups` (MT-17). Over the limit, sign-in or
delivery fails, and `limit.tenant-quota` is emitted. **Decision**: a
directory is not a way around a tenant's limits.
- **DIR-16.** Synchronization never deletes or suspends an account (public
docs). On a domain with `allowScimProvisioning` set, Feature 7's spec
governs instead: there, synchronization only reads, and never creates an
account.
### Changing a domain's directory
- **DIR-17.** A change to `Domain.directoryId`, `Authentication.directoryId`
or any `x:Directory` takes effect on the next request, without a restart.
Sessions already signed in keep running until they end. Tokens the server
has already issued aren't revoked (see open question 5).
- **DIR-18.** Changing a domain's directory never deletes, moves or changes
any account, message, alias, group, app password or API key. When the new
directory first vouches for an address, it updates the account already
there (DIR-14) rather than making a second one.
- **DIR-19.** Accounts the new source doesn't know can't sign in through it
(app passwords and API keys still work, DIR-3). On an LDAP or SQL domain
they also stop receiving mail (DIR-9). The admin front ends say how many
accounts that is before the change is saved.
- **DIR-20.** When a domain moves from an external directory to the internal
one, password credentials synchronized from the directory stay and keep
working. **Decision**: it lets an operator migrate a domain off LDAP or SQL
without resetting every password. The front ends warn that each password is
whatever the directory last supplied, and that accounts disabled in the
directory can sign in again. Accounts that came from OIDC have no password.
They need one set, or an app password.
- **DIR-21.** Writes are checked:
- `directoryId` must name an existing `x:Directory` (the registry's
foreign-key check, as today).
- A directory still named by a domain or by `Authentication` can't be
deleted: `objectIsLinked`, listing what refers to it.
- A directory that fails to open is logged against its id and becomes
unavailable. Every other directory, and the rest of the reload, carries
on (open question 6).
### Tenancy
- **DIR-22.** Directories follow Feature 1:
- A directory with `memberTenantId` belongs to that tenant. It is visible
only to the tenant (MT-1), created by its administrator, and counted
against `maxDirectories`.
- A domain can name only a directory in its own tenant, and a server-level
domain only a server-level directory. Otherwise the write is refused with
`invalidForeignKey`, naming `directoryId` (MT-3).
- `Authentication.directoryId` must name a server-level directory.
**Decision**: the server default is server infrastructure.
- **DIR-23.** A tenant domain whose `directoryId` is null uses the server
default, like any other domain (DIR-1). **Decision**, for compatibility, and
open question 7. MT-3 is still met: the domain links to nothing, and DIR-6
means the server default can't create accounts in any tenant whose domains
point elsewhere.
- **DIR-24.** A tenant administrator can set `directoryId` on its own
tenant's domains, to its own tenant's directories. It can't change
`Authentication`.
### The OIDC directory
The AGPL code does all of this today. It's stated here so the new tests
check it and the fork keeps it.
- **DIR-25. Opening.**
- The server fetches the discovery document. Its `issuer` must equal
`issuerUrl`, ignoring a trailing slash. A mismatch is a configuration
error.
- It then fetches the JWKS from `jwks_uri`.
- Network and provider errors are retried every 3 seconds for up to 30.
Configuration errors aren't retried.
- A directory that doesn't open is unavailable (DIR-4, DIR-5).
- A required scope or configured claim that the provider doesn't advertise
gives a warning, not an error.
- **DIR-26. JWT access tokens.**
- `HS*` algorithms are refused, and HMAC keys in the JWKS are skipped. RSA
(RS and PS), EC P-256 and P-384, and EdDSA are accepted.
- The key is found by `kid`. An unknown `kid` refetches the JWKS, at most
once every 300 seconds. With no `kid`, every key is tried.
- `iss` must equal the discovery document's issuer.
- `exp` is checked, with 60 seconds of leeway.
- If `requireAudience` is set, `aud` must be present and include it.
- Every `requireScopes` entry must appear in `scope`, which may be a
space-separated string or an array.
- **DIR-27. Opaque tokens.** A token that isn't a JWT is sent to the
provider's userinfo endpoint (OIDC Core §5.3). `401` or `403` means sign-in
fails. Token introspection (RFC 7662) isn't used (public docs), and no
audience or scope check is possible on this path (open question 2).
- **DIR-28. Identity.**
- The account address is the `claimUsername` claim if it contains `@`.
Otherwise it is that claim plus `@usernameDomain`, or failing that the
`email` claim. With none of these, sign-in fails.
- When the JWT lacks the address, or a configured name or groups claim,
the userinfo response fills the gaps. The JWT's own claims win.
- Group names without `@` get `@usernameDomain`.
- **DIR-29.** An OIDC directory refuses password sign-in. Under DIR-4, a
password is never tried anywhere else for that domain. App passwords
(DIR-3) remain.
- **DIR-30. Failure classes.** An invalid or rejected token is an
authentication failure: it counts toward the sign-in ban. A network,
provider or configuration fault is an error: it doesn't count, and the
client gets a temporary failure where the protocol has one. The same split
applies to LDAP and SQL (an unreachable server is an error, not a wrong
password).
### Caching
- **DIR-31.** Only these are cached:
- the domain cache, including its directory id, which is invalidated when
`directoryId` changes;
- the built directories (connection pools, OIDC discovery and keys),
rebuilt when any `x:Directory` or `Authentication` changes;
- each OIDC directory's JWKS, refreshed under DIR-26.
- **DIR-32.** Directory answers aren't cached. Every password sign-in and
every bearer token without a server-issued match asks the directory.
**Decision**: no positive cache, which would keep a disabled user in, and no
negative cache, which would keep a fixed user out. Recipient answers
materialize local accounts (DIR-14). Those accounts are what later lookups
find.
## Interfaces
- **Existing, unchanged:** `x:Domain/get` and `/set` with `directoryId`;
`x:Directory/*`; `x:Authentication`; `GET /api/discover/{address}`;
`/api/account`; SASL `PLAIN`, `LOGIN`, `OAUTHBEARER` and `XOAUTH2`; HTTP
Basic and Bearer. What changes is behavior: which directory answers.
- **Errors:** RFC 8620 `SetError` types: `invalidForeignKey` (DIR-22),
`objectIsLinked` (DIR-21), `forbidden` (DIR-13), `overQuota` (DIR-15,
surfaced as a sign-in or delivery failure). Protocols keep their own
failure codes, temporary for DIR-11 and DIR-30.
- **Events:** existing `auth.*` events. DIR-6 drops and DIR-5 dangling ids
emit `auth.warning` with the domain and directory id.
- **New:** none. The DIR-19 count comes from querying accounts on the domain,
which the front ends can already do.
## ihasmail changes
These go in ihasmail-inbuxa, the INBUXA fork of ihasmail, never in public
ihasmail, which stays Stalwart-facing (SPEC.md §5).
- **Domain editor:** a directory picker offering "server default" and the
directories the admin can see (its tenant's, for a tenant admin, DIR-22).
The directory objects themselves are edited in INBUXA Admin, which
ihasmail links to (SPEC.md §5.4).
- **Before saving a directory change:** warn with the DIR-19 count, and, when
moving to the internal directory, the DIR-20 warning.
- **Settings:** hide password and two-factor changes when `/api/account`
lacks `sysAccountPasswordUpdate` (DIR-13), and point users to app
passwords. On an OIDC domain, say that the password is managed by the
organization's sign-in provider.
- **Sign-in:** when the address's domain has its own OIDC provider
(`/api/discover`, DIR-12), send the user there, not to a password form.
How this fits the OAuth contract belongs in `contract.md`.
- **Errors:** show DIR-15 quota refusals and DIR-30 outages in plain words
("your organization's sign-in service is unreachable"), not as a wrong
password.
INBUXA Admin builds its forms from the schema, so it shows `directoryId`
with no work beyond the edition gating it already removes.
## Acceptance tests
Every test runs against inbuxa-server built with no Enterprise code. Tests
needing a directory use the containers the AGPL suite already has
(`tests/src/utils/containers.rs`): OpenLDAP (`osixia/openldap`, fixtures in
`tests/docker/ldap`) and Keycloak (realm in `tests/docker/keycloak`). The
per-domain tests need a second Keycloak realm, or a Dex container, as a
second provider. The OIDC tests go in a new module that replaces the gated
`oidc::test()` call. Marked **(compat)**: also run against a copy of INBUXA's
data.
1. Domain A on LDAP, domain B on SQL, domain C on none, and no server
default: A and B sign in against their own directories, C against the
internal one. With SQL made the server default, C signs in against SQL,
and A is unchanged (DIR-1). *LDAP, SQL (SQLite).*
2. A has LDAP and the LDAP server is stopped: A's password sign-in fails,
even for a user with an internal password on A. B is unaffected (DIR-4).
*LDAP.*
3. `directoryId` naming a directory that failed to open: sign-in fails and
never reaches the internal store (DIR-5). *None.*
4. A's LDAP returns an account on domain B: refused, and nothing is created
(DIR-6). Aliases and groups on B are dropped with a warning. *LDAP.*
5. OAUTHBEARER naming `alice@a` with a valid token for `bob@a`: refused
(DIR-7). *Keycloak.*
6. App password on an LDAP-backed domain works while LDAP is stopped (DIR-3).
*LDAP.*
7. Mail to an LDAP user who has never signed in is accepted and creates the
account (DIR-9, DIR-14). Mail to an internal-only account on that domain
is rejected. *LDAP.*
8. Mail to an OIDC domain address that never signed in is rejected. After a
pre-created account exists, it's accepted (DIR-10). *Keycloak.*
9. Mail to an LDAP domain with LDAP stopped gets a `4xx` (DIR-11). *LDAP.*
10. `/api/discover` for a user on A (provider 1) and a user on B (provider 2)
returns each provider's document. The PACC record differs likewise
(DIR-12). *Two Keycloak realms.*
11. A password change on an external-directory account: `forbidden`. It's
allowed again after the domain moves to the internal directory (DIR-13,
DIR-20). *LDAP.*
12. OIDC first sign-in creates the account with name, groups and tenant.
Second sign-in with an empty groups claim clears the groups. With no
groups claim, they stay (DIR-14). *Keycloak.*
13. Tenant at `maxAccounts`: OIDC first sign-in fails with
`limit.tenant-quota` (DIR-15). *Keycloak.*
14. Moving a domain from LDAP to OIDC keeps every account and message, and
the first OIDC sign-in reuses the existing account (DIR-18). *LDAP,
Keycloak.*
15. Deleting a directory a domain uses: `objectIsLinked` (DIR-21). A tenant
domain naming a server-level directory: `invalidForeignKey` (DIR-22).
*None.*
16. JWT validation: wrong issuer, wrong audience, a missing required scope,
expired beyond 60 seconds, `HS256`, and an unknown `kid` are each
refused. A rotated key is picked up (DIR-26). *Keycloak.*
17. Opaque token accepted through userinfo, and one revoked at the provider
is refused (DIR-27). Username without `@` plus `usernameDomain` resolves
(DIR-28). Password sign-in to an OIDC domain is refused (DIR-29).
*Keycloak.*
18. Provider stopped: sign-in fails as a temporary error, and 20 attempts
from one IP don't trigger the sign-in ban. 20 bad tokens do (DIR-30).
*Keycloak.*
19. Changing `directoryId` takes effect on the next sign-in with no restart
(DIR-17, DIR-31). *LDAP.*
20. **(compat)** Every domain in INBUXA's data signs in against the same
source as before cutover. Any domain with `directoryId` set is listed
first (see "Where the fork stands today").
## Open questions / to observe
1. **Explicit internal directory.** Under DIR-1, a domain can't opt out of a
server default and use the internal directory. The data model has no way
to say so. Needed? If it is, it's a fork-namespace field, not a change to
upstream's.
2. **Opaque tokens and audience.** On the userinfo path (DIR-27),
`requireAudience` and `requireScopes` can't be enforced. Should a
directory with either set refuse opaque tokens, or offer RFC 7662
introspection (which needs client credentials the schema has no field
for)? The docs name `requireAudience`'s default as `stalwart`, and the
schema has no default. Observe which one a new directory gets.
3. **Local credentials outlive the directory.** App passwords and API keys
keep working for a user disabled in the directory (DIR-3). Should
synchronization, or a failed directory sign-in, suspend them?
4. **ID tokens as access tokens.** DIR-26 doesn't check `typ` (RFC 9068
`at+jwt`). An ID token whose `aud` equals `requireAudience` would pass.
Check `typ` when present, or advise a distinct audience?
5. **Revocation on a directory change.** DIR-17 leaves server-issued tokens
valid. That ties to the contract's token revocation (SPEC.md §5.2).
6. **One broken directory.** When one directory fails to open, the build
error is recorded, and the non-certificate reload path applies only when
there were no errors (`cache/reload.rs`). Observe on a local build whether
one bad directory blocks unrelated setting changes. DIR-21 requires it
doesn't.
7. **Tenant domains and the server default.** DIR-23 lets a tenant's domain
with no directory use the server default. Alternative: tenant domains
default to the internal directory. Settle once INBUXA's data shows whether
any tenant domain relies on a default.
8. **Which snippets in `cache/directory.rs` matter.** The strip report shows
6 snippets removed from that file, 2 from `authentication.rs`, 2 from
`auth/mod.rs` and 2 from `cache/principals.rs`. This spec doesn't say what
they did and doesn't need to. The rules they may have enforced (quota,
SCIM authority, tenant checks) are specified here from other sources.
+523
View File
@@ -0,0 +1,523 @@
# Feature spec: scale-out storage
Status: draft, 2026-09-18. Feature 8 in SPEC.md §4. Lowest priority of the
eight.
## Provenance
Written for the clean room (SPEC.md §3). Sources, and nothing else:
| Source | License | Used for |
|---|---|---|
| Stalwart's registry schema: `resources/schema/schema.json.gz` and `crates/registry/src/schema/*.rs` in this repository (v0.16.22) | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | The config objects and fields, what upstream flags as Enterprise, which variants may be shard members |
| This repository's AGPL code after the strip: `crates/store` (`lib.rs`, `build/`, `dispatch/`, `backend/{postgres,mysql}/main.rs`, `write/blob.rs`, `query/log.rs`), `crates/services/src/task_manager/maintenance.rs`, `crates/types` (`blob_hash.rs`, `type_state.rs`), `crates/trc` event names, `tests/src/store` | AGPL-3.0-only (Enterprise code was stripped before commit) | The seams, what's missing, how blobs and change ids are keyed today |
| The strip report, `docs/fork/strip-reports/v0.16.22.json` | Ours | Which files and how many snippets were removed. It records counts and paths only, no removed text |
| Stalwart documentation (`website` repo): `docs/storage/backends/composite/{index,sql-replica,sharded-blob,sharded-in-memory}.md`, the same four under `docs/0.15/`, the "Read replicas" sections of `docs/storage/backends/{postgresql,mysql}.md`, and `docs/ref/object/{data-store,blob-store,in-memory-store,search-store}.md` | Unlicensed public documentation: facts used, prose not copied | What each composite store does, which variants it takes, the "keep the shard list stable" rule, where `readReplicas` applies |
| PostgreSQL documentation: "Hot Standby" and "System Administration Functions" (current) | PostgreSQL License | Standby visibility, recovery conflicts, `pg_is_in_recovery()`, `pg_last_wal_replay_lsn()`, `pg_current_wal_lsn()` |
| MySQL 8.4 Reference Manual: `SHOW REPLICA STATUS`, replica options | Public documentation: facts used | `Seconds_Behind_Source` and when it's NULL, the `REPLICATION CLIENT` privilege, `replica_preserve_commit_order` |
No Enterprise-only file or snippet was used. The drafting session is a fresh
one that never saw Enterprise code, upstream's or anyone else's. The removed
files are known here only by their paths in the strip report. Nothing in this
spec describes how upstream implements any of it. Where a behavior couldn't be
settled from these sources, it's marked **Decision** or listed under "Open
questions / to observe".
Disclosure: the drafting session saved its own listing of the documentation
repository's file tree under `/tmp/claude-1000/`, a path the rules bar because
upstream checkouts live there. It grepped that one file, its own `gh api`
output, then deleted it, and read nothing else under that path.
There is no "Observed" section. INBUXA runs a single store, so there is no
live Enterprise deployment of these features to probe.
## What it is
Three ways to spread storage over more than one backend, for installs too
large for one database or one bucket:
- **SQL read replicas.** A PostgreSQL or MySQL store gets a list of replicas.
Writes go to the primary. Some reads go to the replicas, which takes load
off the primary.
- **Sharded blob store.** Message bodies, attachments and other blobs are
spread over two or more blob backends. Each blob lives on one of them,
chosen from its key.
- **Sharded in-memory store.** Short-lived data (rate-limit counters, locks,
temporary tokens) is spread over two or more Redis-type backends the same
way. The same object serves a sharded lookup store.
Upstream ships these only in its Enterprise Edition. inbuxa-server ships them
to everybody, with no edition check.
**Priority.** INBUXA runs one store and doesn't need any of this. It's the
last feature in SPEC.md §4, and nothing else waits on it. It matters for one
reason beyond scale: configs written for upstream Enterprise that use these
fields must open in inbuxa-server and do what they say, rather than being
silently ignored (see "What the fork has today").
## What the fork has today
The strip removed four whole files under `crates/store/src/backend/composite/`
(`mod.rs`, `read_replica.rs`, `sharded_blob.rs`, `sharded_lookup.rs`) and
snippets from these files: `lib.rs` (6), `backend/mod.rs` (1),
`backend/mysql/main.rs` (1), `backend/postgres/main.rs` (1), `build/blob.rs`
(2), `build/lookup.rs` (1), `build/memory.rs` (2), `dispatch/blob.rs` (6),
`dispatch/lookup.rs` (10), `dispatch/mod.rs` (1), `dispatch/search.rs` (6),
`dispatch/store.rs` (8). This spec doesn't guess what they held. What remains:
- **The config is complete.** The registry schema and its generated Rust
types still carry every field: `readReplicas` on `x:PostgreSqlStore` and
`x:MySqlStore`, the `Sharded` variant of `x:BlobStore`, `x:InMemoryStore` and
`x:LookupStore`, and `x:ShardedBlobStore` / `x:ShardedInMemoryStore`. The
server accepts and stores them.
- **Replicas are connected to, then dropped.** `PostgresStore::open` and
`MysqlStore::open` build a connection pool for each `readReplicas` entry
and put them in a local `replicas` list. Nothing uses the list, and `open`
returns the primary alone. Replica pools inherit everything from the primary's
config except host, port, database, user, secret (and `options` on
PostgreSQL). Both drivers connect lazily, so today a configured replica
costs nothing and does nothing. The one exception is a bad replica secret,
which fails `open`. **The replicas are ignored without a warning.**
- **Sharded stores fail to build.** `BlobStore::build`,
`InMemoryStore::build` and `LookupStores::parse_stores` have no arm for
`Sharded`. It falls to the catch-all, which reports "Binary was not compiled
with the selected … backend". That message is wrong: no build option
provides it.
- **The store enums have no composite variant.** `Store`, `BlobStore` and
`InMemoryStore` in `lib.rs` list only single backends. Every method in
`dispatch/{store,blob,lookup,search}.rs` is a `match` over those variants.
Those matches are the seams where composite variants go.
- **Leftovers from the strip, to clean up when this lands:**
- `lib.rs`, `Store::is_same`, still has an arm for
`Store::SQLReadReplica` under `#[cfg(all(feature = "enterprise", …))]`.
The variant doesn't exist. It compiles only because the feature is off.
- `crates/store/Cargo.toml` still declares `enterprise = []`. SPEC.md §2.3
says the feature is gone.
- `build/lookup.rs` gets an unused-import warning for `LookupStore`. It's one
of the six in SPEC.md §2.2b. The `inbuxa` binary's default features are
`["rocks"]` only. In that build every remaining arm that names
`LookupStore` is behind a backend feature (`postgres`, `mysql`, `sqlite`,
`redis`), so the import is unused. A `Sharded` arm needs no backend
feature, so it uses the import again. Leave the warning until then. Don't
silence it.
- Also in `build/lookup.rs`, `LookupStore::RedisSentinel` has no arm either,
even with `redis` on. Whether that's upstream's own gap isn't known. It
isn't part of this feature. Noted for triage.
- **Related, and unaffected:** blob purging already works in 256 slices by
the first byte of the blob hash (`purge_blobs`, and `shardIndex` on
`x:TaskStoreMaintenance`). That's slicing of the purge task, not store
sharding, and it isn't flagged Enterprise.
**Where the code goes.** SPEC.md §2.3 puts rebuilt features in a fork-owned
crate. That doesn't work here. The composite variants must be variants of
`store`'s own enums, and a separate crate that depends on `store` can't also
be depended on by it. **Decision:** the rebuild lives in `crates/store` in new
fork-owned files (AGPL-3.0-only headers) under
`crates/store/src/backend/scaleout/`, with one variant added to each enum and
one arm to each dispatch `match`. It isn't put back under `backend/composite/`,
because that path belongs to upstream's stripped files and would collide on
every sync.
## Data model
Unchanged from upstream, so existing configs open as they are. No field is
added. Upstream flags only two fields as Enterprise, and in inbuxa-server
they're ordinary: `x:MySqlStore.readReplicas` and
`x:PostgreSqlStore.readReplicas`. The sharded variants and objects aren't
flagged in the schema at all, though upstream's documentation calls them
Enterprise.
### Read replicas
`readReplicas` is a list on `x:PostgreSqlStore` (entries `x:PostgreSqlSettings`)
and `x:MySqlStore` (entries `x:MySqlSettings`). Each entry has `host`, `port`,
`database`, `authUsername`, `authSecret`, and on PostgreSQL `options`. Defaults
match the store's: user and database `stalwart`, port 5432 or 3306.
A PostgreSQL or MySQL store object can appear as the `x:DataStore`,
`x:BlobStore`, `x:SearchStore`, a `x:StoreLookup` store, a member of a sharded
blob store, and the `x:TracingStore` or `x:MetricsStore`. The field is the same
in every place.
Upstream's 0.15 documentation describes an older standalone
`sql-read-replica` store type naming a primary and replica store ids. The 0.16
registry has no such type. It's out of scope. Configs from before 0.16 go
through upstream's own migration.
### Sharded stores
| Object | Field | Type | Members may be |
|---|---|---|---|
| `x:ShardedBlobStore`, as `x:BlobStore` `@type: "Sharded"` | `stores` | list of `x:BlobStoreBase`, at least 2 | `S3`, `Azure`, `FileSystem`, `FoundationDb`, `PostgreSql`, `MySql` |
| `x:ShardedInMemoryStore`, as `x:InMemoryStore` `@type: "Sharded"` | `stores` | list of `x:InMemoryStoreBase`, at least 2 | `Redis`, `RedisCluster`, `RedisSentinel` |
| `x:ShardedInMemoryStore`, as a `x:StoreLookup`'s `store`, `@type: "Sharded"` | `stores` | as above | as above |
The base types leave out `Default` and `Sharded`, so a shard can't be the data
store by reference and shards can't nest. A member can still be a PostgreSQL or
MySQL store with its own `readReplicas`.
Permissions are the existing `sysBlobStore*`, `sysInMemoryStore*`,
`sysDataStore*`, `sysSearchStore*` ones. Nothing new.
## Required behavior
Each requirement has an ID, and tests name the IDs they check.
### General
- **ST-1.** Off unless configured. An install with no `readReplicas` entries
and no `Sharded` variant behaves exactly as it does today: the same
connections, queries, events and code paths, and no extra work per request.
An empty `readReplicas` list is the same as none.
- **ST-2.** Existing configs open unchanged, with no edition check. A config
with `readReplicas` or `Sharded` either works as this spec says, or the
server reports why at startup (ST-15, ST-23, ST-29). Silently ignoring a
configured replica or shard, as the fork does today, isn't allowed.
- **ST-3.** Composite stores are transparent. Every check on what kind of
backend a store is answers as its primary does (for replicas) or as its
members do (for shards): `Store::id()`, `is_sql()`, `is_pg_or_mysql()`,
`SearchStore::is_postgres()` / `is_mysql()`, `InMemoryStore::is_redis()`.
So a SQL directory on a data store with replicas is still a SQL directory,
and PostgreSQL full-text search still uses PostgreSQL's syntax.
- **ST-4.** No new Cargo feature. **Decision:** composite stores are always
compiled. A composite is usable whenever its member backends are compiled
in. A member whose backend isn't compiled gets the existing "not compiled"
build error, naming the member's position in the list.
### Read replicas: routing
- **ST-5.** The primary is the default. Every operation goes to the primary
unless ST-6 names it. That includes:
- every write: `write` batches, `delete_range`, `delete_documents`,
`purge_store`, `create_tables`, account destruction;
- every read that feeds a write: value assertions, counters read back from
a write (`add_and_get`), document-id assignment, `try_lock`'s read of the
current lock, quota checks;
- the in-memory store when it's the data store (`InMemoryStore::Store`):
rate limits, locks, tokens;
- the registry and settings, bootstrap, recovery mode, cluster membership
and the task queue;
- maintenance, purges, migrations, reindexing, backup and export;
- `sql_query`. **Decision:** the statement is operator-written (SQL
directories, lookup stores, Sieve and expression queries), so the server
can't tell a read from a write. It always goes to the primary.
- **ST-6.** Replica-eligible reads are opted into at the call site, never
inferred by the store. **Decision:** the store keeps its current API, which
always means the primary, and adds an explicit read handle for a given
account (see "Interfaces"). The first call sites to use it:
- JMAP `/get`, `/query`, `/changes` and `/queryChanges` on account data,
and blob download;
- IMAP `FETCH`, `SEARCH`, `STATUS` and `LIST`, and POP3 `RETR` and `TOP`;
- WebDAV, CalDAV and CardDAV `GET`, `PROPFIND` and `REPORT`;
- full-text search queries (`SearchStore::query_account`, `query_global`)
on a PostgreSQL or MySQL search store;
- blob reads from a PostgreSQL or MySQL blob store (ST-9).
A read in a request that also writes goes to the primary: every method in a
JMAP request after its first `/set`, `/copy` or `/import`, and every read
inside an IMAP command that writes (`STORE`, `COPY`, `MOVE`, `APPEND`,
`EXPUNGE`).
- **ST-7.** Read-your-writes. After a write for an account returns to the
client, every later read for that account sees it, on any node. The
mechanism, a **Decision**:
1. Every write for an account produces a change id (the per-account
counter already returned by the write, `AssignedIds`). Each node keeps,
per account, the highest change id it has written or heard of (its
*high-water mark*). It learns of other nodes' writes from the
`StateChange` broadcasts it already receives, which carry the account and
change id.
2. When the server runs more than one node, a write also records the
account's high-water mark in the in-memory store, with an expiry of twice
the lag limit (ST-11). This happens before the write's result goes back
to the client, so no node can answer from a replica before it can see the
mark. A single node keeps the mark in memory only.
3. Before an account's read goes to a replica, the server compares the
replica's latest change id for that account with the mark: the local
mark, and the shared one when there is one. If the replica is behind, the
read goes to the primary. The replica's figure may be cached per request.
4. A state the client presents raises the mark for that read: JMAP
`sinceState` and `ifInState`, EventSource and push resumption, IMAP
`CONDSTORE` mod-sequences and `QRESYNC`.
So after `Email/set` creates a message, the next `Email/get` finds it,
`Email/changes` from the old state lists it, and a renamed mailbox reads
back with its new name, whichever node answers.
- **ST-8.** A miss on a replica isn't final. An id that a replica reports
missing is looked up on the primary before the server answers `notFound`,
or its equivalent in other protocols. This backs ST-7 up at the cost of one
primary read per miss.
- **ST-9.** Blobs on a SQL store with replicas. Reads may go to a replica
(ST-6). A blob the replica doesn't have is read from the primary. Writes and
deletes go to the primary. Most blob keys are content hashes and never
change. The few named blobs that are overwritten (the spam classifier model
and training data, installed app resources) may read stale for up to the lag
limit. That's accepted.
### Read replicas: lag, failure and validation
- **ST-10.** Lag is measured, not assumed. Each node samples every replica
once a second (**Decision**):
- **PostgreSQL:** the replica must answer `pg_is_in_recovery()` true. The
node samples the primary's `pg_current_wal_lsn()` with a timestamp, and the
replica's `pg_last_wal_replay_lsn()`. The lag is the age of the oldest
primary sample the replica hasn't yet replayed. This stays correct when
the primary is idle, which a replay timestamp doesn't.
- **MySQL with GTIDs:** the same scheme, comparing the primary's
`@@global.gtid_executed` samples with the replica's using `GTID_SUBSET()`.
- **MySQL without GTIDs:** `Seconds_Behind_Source` from
`SHOW REPLICA STATUS`, which needs the `REPLICATION CLIENT` privilege.
NULL means replication is stopped, and the replica is unhealthy.
- **ST-11.** Lag limit. A replica more than 5 seconds behind gets no reads. It's
admitted again once it's under 2.5 seconds. **Decision:** fixed values in the
first version, because configuring them needs a new field, and new fields
wait for the fork's namespace (SPEC.md §8). A replica whose lag can't be
measured (missing privilege, unsupported server) gets no reads, and the
server logs why once at startup.
- **ST-12.** Replica failure never fails a request. A connection error, a
timeout, or a query cancelled by the standby (PostgreSQL cancels queries
that conflict with replay, subject to `max_standby_streaming_delay`) retries
that read once on the primary. The replica is marked down, gets no reads, and
is probed again every 10 seconds (**Decision**). Down and up are logged as
events.
- **ST-13.** Choosing a replica. Reads go round-robin across the replicas
that are up and under the lag limit. Each replica has its own pool, sized by
the primary's pool settings, as today.
- **ST-14.** Primary failure. Writes fail as they do today with one database.
The server doesn't fail over or promote a replica: that's the database's job,
behind the host name the primary entry points at. While the primary is down,
replica-eligible reads whose check in ST-7 passes keep working. Everything
else fails as it does today.
- **ST-15.** Validation at startup, per replica. A replica is left out, with
an error event naming it, and the server runs on the rest (primary alone if
need be), when:
- it's the primary itself: the same host, port and database;
- it isn't read-only: PostgreSQL `pg_is_in_recovery()` is false, or MySQL
has neither `read_only` nor `super_read_only` on;
- it isn't a copy of this primary. **Decision:** at startup the node writes
a random marker value to the primary, and the replica must show it within
the lag limit. This catches a replica of some other database;
- MySQL applies in parallel without preserving commit order (parallel
workers above 0 and `replica_preserve_commit_order` off). Out-of-order
commits would break the change-id check in ST-7.
A replica that fails later checks is handled by ST-12, not left out for good.
The primary's checks are unchanged.
### Sharded blob store
- **ST-16.** Placement. A blob's *home* is `xxh3_64(key) mod N`: the 64-bit
XXH3 hash, seed 0, over the full key bytes, where N is the number of entries
in `stores`, in the order listed. The hash crate is already a dependency.
**Decision**, fixed forever once shipped. Upstream's documentation says only
"hash and modulus", so this doesn't claim to match upstream's placement. Keys
are usually 32-byte content hashes, but some are names (the spam classifier's
keys), which is why the whole key is hashed rather than its first bytes.
- **ST-17.** Reads. A read goes to the home shard. If the home shard doesn't
have the blob, the other shards are tried in list order. If one has it, the
blob is returned and a misplaced-blob event is logged with the key and the
shard it was found on. Nothing is moved automatically. If no shard has it, the
result is "not found", as today. A blob is only read after the data store
says it exists, so this probing happens only for blobs placed under an
earlier shard list, or ones that really are lost.
- **ST-18.** Writes and deletes. A write goes to the home shard only. A
delete goes to the home shard. If the home shard reports that it didn't have
the blob, the other shards are tried until one deletes it. Blob purging
(`purge_blobs`) needs no change: it deletes through the same call.
- **ST-19.** Changing the shard list. Appending a shard, or reordering, keeps
every existing blob readable, through ST-17, at the cost of extra lookups for
blobs whose home moved. A named blob overwritten after its home moved leaves
its old copy behind on the old shard. That's accepted: the new home is read
first. **Moving blobs to their new home (resharding, rebalancing) is out of
scope.**
- **ST-20.** Layout record. The first time a sharded blob store opens, the
server stores a description of the list in the data store: each member's
kind and location (bucket, container, path, host and database), never its
secrets. On every later start:
- the same list: nothing to do;
- members added or reordered: a warning event naming what changed, and the
record is updated;
- a recorded member missing from the list: the blob store refuses to open,
naming the missing member, because blobs on it would be unreachable.
**Decision.** How an operator confirms that a removal is intended is an
open question.
- **ST-21.** One shard failing. Operations whose home is the failing shard
fail with that backend's usual error, and the rest carry on. A read whose home
shard errors (rather than reporting a miss) returns the error without probing
the others. A write whose home shard is down fails, so local delivery answers
with a temporary failure and the message stays queued, as it would with a
single blob store down. **Decision:** writes aren't redirected to another
shard. A redirected named blob would leave an older copy on its recovered
home, and that copy would be read first.
- **ST-22.** Validation. At least two members (the schema already requires
it). Two members pointing at the same place (the same bucket and prefix, the
same directory, the same database) are refused. Every member must open at
startup, or the blob store fails to build with an error naming the member's
position.
### Sharded in-memory and lookup stores
- **ST-23.** Placement. Every single-key operation goes to the key's home,
chosen as in ST-16 over the full key, prefix byte included: `key_set`,
`key_get`, `key_exists`, `key_delete`, `counter_incr`, `counter_get`,
`counter_delete`, rate limits, `try_lock` and `remove_lock`. A lock and its
release therefore always meet on the same member.
- **ST-24.** Operations over many keys go to every member:
`key_delete_prefix` and `purge_in_memory_store`. They succeed only if every
member does, and a failure names the member.
- **ST-25.** No fallback reads. ST-17 doesn't apply here. Probing other members
would break locks and counters. Changing the member list moves keys, and data
on the old home is abandoned until it expires: rate-limit windows start
again, and temporary tokens and greylist entries may be lost. That's accepted
for short-lived data. Locks are the risk: two nodes with different lists
could both take the same lock. So:
- **ST-26.** Every node must use the same member list. As in ST-20, the list
is recorded in the data store. A node starting with a different list logs an
error event naming the difference, then runs with its own list.
**Decision:** it doesn't refuse, because the data is short-lived and
refusing would block a planned change. Operators change the list by
restarting every node together.
- **ST-27.** One member failing. Operations on its keys fail as they would
with that single Redis down today. Other keys are unaffected. What callers do
with that error (rate limits, locks, greylisting) is today's behavior,
unchanged (see "Open questions").
- **ST-28.** A sharded lookup store (`x:StoreLookup` with a `Sharded` store)
behaves as ST-23 to ST-27. The existing rule that each namespace appears once
still applies. `into_store()` returns none, as for Redis, so SQL queries
against it stay unsupported.
- **ST-29.** Validation as ST-22: at least two members, no duplicates, and
every member opens.
### Observability
- **ST-30.** **Decision:** no new event names in the first version, because
new names belong in the fork's namespace (SPEC.md §8). Replica and shard
problems are logged with the existing events (`store.postgresql-error`,
`store.mysql-error`, `store.redis-error`, `store.s3-error`,
`store.azure-error`, `store.filesystem-error`, `store.pool-error`), with
details naming the replica host or the shard's position. Replica down and up,
lag over the limit, and misplaced blobs are logged the same way.
## Interfaces
- **Config, unchanged:** the fields and variants under "Data model", through
`x:DataStore`, `x:BlobStore`, `x:InMemoryStore`, `x:SearchStore` and
`x:StoreLookup`, with their existing permissions.
- **Inside the server, new:** a read handle for replica-eligible reads, for
example `store.replica_read(account_id)`. It carries the account's
high-water mark (ST-7). On a store with no replicas it's the store itself, so
call sites don't branch (ST-1). Every existing `Store` method keeps meaning
"primary".
- **Inside the server, new:** the per-account high-water mark, fed by write
results and `StateChange` broadcasts, shared through the in-memory store when
there's more than one node.
- **Data store keys, new:** the shard layout records (ST-20, ST-26), under a
key in the fork's namespace.
- **JMAP, IMAP, SMTP and the rest:** no change visible to clients.
- **ihasmail:** nothing required. The storage settings forms come from the
schema, which doesn't change. No new ihasmail strings, so no translation
work.
## Acceptance tests
Every test runs against inbuxa-server built with no Enterprise code, with the
backend features the test needs (`postgres`, `mysql`, `redis`, `s3`). The
default `inbuxa` build compiles only RocksDB.
**Needs nothing extra:**
1. A single-store install (RocksDB, and PostgreSQL with no replicas) opens no
extra connections, and the existing store suites in `tests/src/store` pass
unchanged (ST-1).
2. Sharded blob store over three `FileSystem` members: each blob lands on
exactly one member, on its ST-16 home. The existing blob suite
(`tests/src/store/blob.rs`) passes against it (ST-16, ST-18).
3. Append a fourth `FileSystem` member: every existing message still
downloads over JMAP and IMAP. New blobs land by the new mapping. Reads of
moved blobs log the misplaced-blob event (ST-17, ST-19, ST-20).
4. Remove a member: the blob store refuses to open and names it (ST-20).
5. Make one member's directory unreadable: blobs on the others still read.
Delivery of a message whose home is that member fails temporarily and stays
queued (ST-21).
6. Purge after deleting messages: unlinked blobs are removed from whichever
member holds them, including misplaced ones (ST-18).
7. Two members naming the same directory: refused (ST-22).
8. A config with `Sharded` no longer reports "not compiled" (ST-2).
**Needs a PostgreSQL primary with a streaming replica:**
9. An upstream-written config with `readReplicas` opens. JMAP `Email/get`
and IMAP `FETCH` are served by the replica, and the replica's statement log
shows no writes, locks or rate-limit traffic (ST-2, ST-5, ST-6).
10. With replay paused (`pg_wal_replay_pause()`): `Email/set` creates a
message, and a separate `Email/get` finds it. `Mailbox/set` renames a
mailbox, and `Mailbox/get` shows the new name. `Email/changes` from the
old state lists the new message (ST-7, ST-8).
11. Two nodes, replay paused: `/set` through node A, then `/get` through node
B straight away, sees the change (ST-7).
12. Replay paused for 10 seconds: the replica gets no reads, then gets them
again after replay resumes and catches up (ST-10, ST-11).
13. Replica stopped: requests keep succeeding, and the down and up events are
logged. Replica restarted: back in use within about 10 seconds (ST-12).
14. Replica entry pointing at a writable, unrelated database: left out at
startup with an error, and the server runs on the primary (ST-15).
15. A SQL directory on the data store with replicas still authenticates, and
PostgreSQL full-text search works with the search store on a replica
(ST-3, ST-6).
16. Primary stopped: eligible reads still answer, and writes fail as with a
single database (ST-14).
**Needs a MySQL source with a replica:**
17. Tests 9, 10 and 12 with GTIDs on (ST-10).
18. With GTIDs off, with and without `REPLICATION CLIENT`: lag comes from
`Seconds_Behind_Source` in the first case, and the replica is left out with
a logged reason in the second (ST-10, ST-11).
19. Parallel replica with `replica_preserve_commit_order` off: left out at
startup (ST-15).
**Needs two or more Redis servers:**
20. Sharded in-memory store over two Redis servers: rate limits, locks and
the `resetRateLimiters` and `removeLock*` maintenance types behave as with
one Redis. A prefix delete clears keys on both (ST-23, ST-24).
21. One Redis stopped: keys homed on the other still work (ST-27).
22. Two nodes started with different member lists: the second logs the
difference (ST-26).
23. A sharded lookup store serves reads and writes to its namespace (ST-28).
**Needs S3 or Azure (optional):** test 2 with mixed members (S3, FileSystem
and PostgreSQL), to show members of different kinds work together (ST-16).
## Open questions / to observe
- **Placement compatibility with upstream.** Upstream's hash isn't public,
so ST-16 is the fork's own. An install coming from upstream Enterprise with
a sharded blob store would read every blob through ST-17's probing, which
works but is slow. Is a one-off relocation tool worth building? There's no
such install to observe: INBUXA doesn't shard.
- **Configurable limits.** The lag limit, probe interval and re-probe interval
(ST-10 to ST-12) are fixed until the fork's namespace (SPEC.md §8) allows new
fields.
- **New event names** for replica down and up, lag and misplaced blobs
(ST-30). Same dependency on the namespace.
- **Cost of the shared high-water mark** (ST-7, step 2): one in-memory write
per account write and one read per request, on multi-node installs only.
Measure it against sticky sessions at the load balancer, which would make it
unnecessary.
- **SQL directory queries on replicas.** Sign-in lookups are a large share of
read load, but ST-5 keeps `sql_query` on the primary. A per-directory
opt-in would need a new field.
- **MariaDB, Galera, AlloyDB.** Upstream's documentation lists them. MariaDB's
GTIDs differ from MySQL's, so ST-10 falls back to `Seconds_Behind_Master`
there. None of them is tested.
- **Confirming a removed shard** (ST-20): how an operator says "yes, drop it"
without a new field. Perhaps a recovery-mode command (SPEC.md §6.3).
- **Callers' behavior on in-memory errors** (ST-27): whether a rate-limit
check, lock or greylist lookup that errors lets the request through or
refuses it. To observe on a stock build with Redis stopped, before any code
changes.
- **Replicas on the tracing and metrics stores.** They're feature 6's objects.
ST-4 to ST-15 apply as written, but nothing reads them on a request path yet.
- **`LookupStore::RedisSentinel` has no build arm** in `build/lookup.rs`. It's
outside this feature. Check a stock build to see whether a sentinel lookup
store works at all.
+841
View File
@@ -0,0 +1,841 @@
# Feature spec: SCIM 2.0 provisioning
Status: draft, 2026-09-18. Feature 7 in SPEC.md §4.
## Provenance
Written for the clean room (SPEC.md §3). Sources, and nothing else:
| Source | License | Used for |
|---|---|---|
| This repository's surviving SCIM code: `crates/scim` and `crates/scim-proto` (manifests and stub `lib.rs` files) | AGPL-3.0-only | What exists, dependencies, where the crates are wired in |
| Surviving SCIM tests: `tests/src/scim/conformance.rs`, `oidc.rs`, `tenant.rs` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Observable behavior the tests assert: status codes, error types, tenancy, the OIDC interplay, advertised limits |
| Third-party client driver: `tests/docker/scim/driver.py` and `Dockerfile`, and `tests/src/utils/containers.rs` | Part of this AGPL repository | The lifecycle and IdP payload shapes the server must accept, and what it must return |
| Stalwart's registry schema: `crates/registry/src/schema/*.rs` and `resources/schema/schema.json.gz` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Field shapes and meanings, indexes, validation, the `scimAccess` permission, what upstream flags as Enterprise |
| AGPL code around the feature: `crates/common/src/auth/{credential,authentication}.rs`, `crates/common/src/cache/{directory,principals}.rs`, `crates/jmap/src/registry/mapping/principal.rs` | AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | API-key bearer authentication, just-in-time directory sync, account destruction, what is not there |
| Strip report `docs/fork/strip-reports/v0.16.22.{json,md}` | Ours | Which SCIM files, tests and snippets were removed (names and counts only) |
| Stalwart documentation (`website` repo): `docs/auth/scim/{index,configuration,endpoints,mapping,provisioning}.md`, `docs/auth/scim/providers/{index,entra-id,okta,keycloak}.md`, `docs/auth/authentication/api-key.md` | Unlicensed public documentation: facts used, prose not copied | Endpoints, limits, attribute mapping, authority rules, deprovisioning, provider behavior |
| RFC 7643, RFC 7644, RFC 9865 | IETF | The wire contract: schemas, operations, errors, cursor pagination |
| Spec `features/multi-tenancy.md`, `features/undelete.md` | Ours | Tenancy rules (MT-) and deleted-account handling (UD-) this spec relies on |
No Enterprise-only file or snippet was used. This spec was written by a fresh
session that never saw Enterprise code. The removed files are known here only
by the names the strip report lists. No running server was observed and no
production server was contacted. Where no allowed source settles a behavior,
this spec makes a **Decision** or lists it under "Open questions / to
observe". It never fills a gap from memory of upstream code.
Identity-provider behavior (what Entra ID, Okta and the Keycloak extensions
send) comes from Stalwart's public provider pages and from the payloads in
`driver.py`. The vendors' own SCIM documentation wasn't read for this draft
(see open questions).
## What it is
An identity provider (Entra ID, Okta, a Keycloak extension, a script) is the
system of record for people. SCIM lets it push their accounts into
inbuxa-server: create the mailbox the day a person is hired, keep the name,
aliases and group membership current, suspend the account when they leave,
and delete it when policy says so.
inbuxa-server is the SCIM **service provider** only. It receives requests at
`/scim/v2` on its existing HTTP listeners and applies them to its own
accounts. It never sends SCIM anywhere. SCIM doesn't authenticate mail users.
It's meant to be deployed beside an OIDC (or LDAP or SQL) directory that
does.
Upstream ships this only in its Enterprise Edition, and answers `/scim/v2`
with `403 Forbidden` in its Community Edition. inbuxa-server ships it to
everybody. There is no edition check. It's inert until an operator opens a
domain to it and issues a credential.
## What already exists in the fork
Surveyed 2026-09-18 at the v0.16.22 import.
### Present
| What | State |
|---|---|
| `crates/scim` | `Cargo.toml` (deps: `scim-proto`, `common`, `jmap`, `store`, `registry`, `directory`, `http_proto`, `jmap_proto`, `types`, `utils`, `trc`, `hyper`, `serde`, `serde_json`, `xxhash-rust` with `xxh3`, `icu_locale`; features `test_mode`, `dev_mode`, `enterprise`) and a `src/lib.rs` that is a 5-line AGPL-3.0-only header with no code |
| `crates/scim-proto` | `Cargo.toml` (deps: `serde`, `serde_json`, `hashify`) and the same empty `src/lib.rs` |
| Wiring | `crates/http` and `crates/main` depend on `scim`, and both map their `enterprise` feature onto `scim/enterprise`. Nothing calls into it. The HTTP router (`crates/http/src/request.rs`) has no `scim` path |
| `tests/src/scim/conformance.rs` (177 lines) | Runs `driver.py` in a container: an RFC conformance checker (`scim2-tester` 0.2.8), a full lifecycle with `scim2-client` 0.7.5, and replayed Okta, Keycloak and Entra payloads |
| `tests/src/scim/oidc.rs` (332 lines) | `allowScimProvisioning` against a Keycloak OIDC directory: no just-in-time creation, SCIM attributes survive a login, clearing the flag restores just-in-time sync |
| `tests/src/scim/tenant.rs` (362 lines) | A tenant-scoped client sees and reaches only its tenant, can't provision outside its domains, and can inside them |
| `tests/docker/scim/` | `Dockerfile` (Python 3.12, `scim2-tester`, `scim2-client`, `scim2-models` 0.6.12, `httpx`) and `driver.py` (457 lines) |
| `tests/src/utils/containers.rs` | `ensure_scim_tester`, `scim_tester_exec`, `ensure_keycloak` (with `tests/docker/keycloak/stalwart-realm.json`) |
| Registry schema | `x:Domain.allowScimProvisioning` (bool, default `false`, property 932), `x:UserAccount.externalId` and `x:GroupAccount.externalId` (nullable string, property 933, search-indexed, an empty string fails validation), `Permission::ScimAccess` (`scimAccess`, 660). Stored and serialized. Nothing reads them |
| API keys | `x:ApiKey` credentials on accounts; the `API_…` bearer token format and its validation (`crates/common/src/auth/credential.rs`, `authentication.rs`) are shared AGPL code and work today |
| Account destruction | `schedule_account_destruction` and the `DestroyAccount` task (`crates/jmap/src/registry/mapping/principal.rs`) |
| Just-in-time sync | `synchronize_account` and `synchronize_group` (`crates/common/src/cache/directory.rs`). They have no SCIM authority check, and the domain cache (`DomainCache`) doesn't carry the flag |
### Removed by the strip, to be rebuilt
- **All SCIM code, 33 files.** `crates/scim-proto/src/`: `attributes.rs`,
`etag.rs`, `filter.rs`, `json.rs`, `lib.rs`, `path.rs`,
`message/{bulk,error,list,mod,patch,search}.rs`,
`schema/{group,mod,spc,user}.rs` (16). `crates/scim/src/`: `auth.rs`,
`bulk.rs`, `context.rs`, `discovery.rs`, `error.rs`, `lib.rs`,
`request.rs`, `groups/{get,mod,patch,set}.rs`, `query/{cursor,mod}.rs`,
`users/{get,mod,patch,set}.rs` (17). The file names are the only thing
known about them.
- **Eight test files:** `tests/src/scim/{auth,bulk,discovery,groups,limits,mod,query,users}.rs`.
`mod.rs` held the shared helpers the three surviving files import
(`ScimTest`, `ScimClient`, `SCIM_DOMAIN`, `HTTP_PORT`, `api_key`,
`user_body`, `group_body`, `patch_body`, `query`, `jmap_session_status`)
and `scim_proto` exported `SCHEMA_USER` and `MESSAGE_BULK_REQUEST`. The
strip also removed `pub mod scim;` from `tests/src/lib.rs`, so the
surviving tests aren't compiled.
- **Snippets in shared files.** The report gives counts, not contents. Six
were cut from `crates/common/src/cache/directory.rs`, the just-in-time sync
that SCIM-58 changes, and four each from `crates/http/src/request.rs` and
`crates/http/src/api/mod.rs`. What they held isn't known and doesn't
matter: this spec says what the behavior must be.
- **A dependency not in any spec yet.** Per-domain directories
(`x:Domain.directoryId`, also flagged Enterprise) aren't rebuilt:
`get_directory_for_domain` returns the default directory. `oidc.rs` binds
an OIDC directory to one domain, so it needs that first (see open
questions).
## Data model
Unchanged from upstream, so existing data opens as it is (SPEC.md §7).
Upstream flags three fields as Enterprise. In inbuxa-server they're
ordinary fields, readable and writable over JMAP by anyone with the matching
`sysDomain*` or `sysAccount*` permission.
| Field | Type | Meaning |
|---|---|---|
| `x:Domain.allowScimProvisioning` | boolean, default `false` | SCIM may write accounts on this domain, and SCIM is authoritative for them: just-in-time directory sync stops creating or changing them |
| `x:UserAccount.externalId` | string or null | The identity provider's own identifier for the user. Search-indexed. Not unique in the index |
| `x:GroupAccount.externalId` | string or null | The same, for a group |
Everything else SCIM touches already exists: `x:UserAccount` (`name`,
`domainId`, `emailAddress`, `aliases`, `description`, `locale`, `timeZone`,
`memberGroupIds`, `memberTenantId`, `permissions`, `roles`, `createdAt`),
`x:GroupAccount` (`name`, `domainId`, `description`, `memberTenantId`,
`createdAt`), `x:ApiKey` (`description`, `secret`, `permissions`,
`allowedIps`, `expiresAt`, `createdAt`), and the permissions `authenticate`,
`scimAccess`, `sysAccountGet`, `sysAccountCreate`, `sysAccountUpdate`,
`sysAccountDestroy` and `unlimitedRequests`.
There is no "provisioned by SCIM" marker and no modification timestamp on
accounts. Nothing new is stored. The ETag (SCIM-44) is computed, and cursors
(SCIM-49) carry their own state.
### How SCIM resources map
**User** (`urn:ietf:params:scim:schemas:core:2.0:User`) is an
`x:UserAccount`:
| SCIM attribute | Account field | Mutability | Notes |
|---|---|---|---|
| `id` | the account's id | readOnly | The same id JMAP uses |
| `externalId` | `externalId` | readWrite | case-exact |
| `userName` | `name` + `domainId` | readWrite, required | A full email address |
| `displayName` | `description` | readWrite | |
| `name.formatted` | `description` | readWrite | The same stored value as `displayName` |
| `active` | the effective `authenticate` permission | readWrite | SCIM-27 |
| `emails` | primary address, then `aliases` | primary readOnly, others readWrite | SCIM-25 |
| `locale`, `preferredLanguage` | `locale` | readWrite | One stored value |
| `timezone` | `timeZone` | readWrite | IANA name |
| `groups` | `memberGroupIds` | readOnly | `value`, `display`, `$ref` |
| `meta` | `createdAt`, computed version | readOnly | `resourceType`, `created`, `location`, `version` |
**Group** (`urn:ietf:params:scim:schemas:core:2.0:Group`) is an
`x:GroupAccount`:
| SCIM attribute | Account field | Mutability | Notes |
|---|---|---|---|
| `id` | the group's id | readOnly | |
| `externalId` | `externalId` | readWrite | case-exact |
| `displayName` | `description` | readWrite, required | Unique among groups in scope |
| `members` | the members' `memberGroupIds` | readWrite | Users only |
| `meta` | `createdAt`, computed version | readOnly | Version covers membership |
## Required behavior
Each requirement has an ID, and tests name the IDs they check.
### The endpoint
- **SCIM-1.** SCIM is served under `/scim/v2` on every HTTP listener, beside
JMAP. Every response, errors included, has content type
`application/scim+json`. Requests with `application/scim+json` or
`application/json` bodies are accepted (RFC 7644 §3.1).
- **SCIM-2.** Paths and methods are those in "Interfaces". An unknown path
under `/scim/v2` answers `404`. A known path with the wrong method answers
`405` with an `Allow` header. `OPTIONS` on any path answers `204`. `/Me`
answers `501` for every method, which RFC 7644 §3.11 allows: the caller is
a service account, not a provisionable user.
- **SCIM-3.** The discovery endpoints (`/ServiceProviderConfig`,
`/ResourceTypes`, `/Schemas`, and their single-item forms) need no
authentication and return no account data. They're subject to the anonymous
HTTP rate limit. A `filter` parameter on them answers `403` (RFC 7644 §4).
- **SCIM-4.** `/ServiceProviderConfig` is fixed: `patch` supported, `bulk`
supported with `maxOperations` 1000 and `maxPayloadSize` 1048576,
`filter` supported with `maxResults` 200, `changePassword` not supported,
`sort` supported, `etag` supported, and one authentication scheme of type
`oauthbearertoken`, marked primary. It also carries the RFC 9865
`pagination` object: `cursor` true, `index` true,
`defaultPaginationMethod` `index`, `defaultPageSize` 100, `maxPageSize`
200, `cursorTimeout` 3600 (SCIM-49), and `interopProfileConformant`
`false` (SCIM-33). **Decision:** any `documentationUri` points at INBUXA's
own documentation, never upstream's.
- **SCIM-5.** `/ResourceTypes` lists `User` (endpoint `/Users`) and `Group`
(endpoint `/Groups`). **Decision:** no `schemaExtensions` are listed. The
enterprise User extension is accepted in requests (SCIM-33) but not
published, since nothing in it is stored.
- **SCIM-6.** `/Schemas` publishes only the attributes in the mapping tables
above, with RFC 7643 characteristics (`mutability`, `returned`,
`uniqueness`, `caseExact`, `required`). `password` isn't published.
`members.type` publishes `User` as its only canonical value.
`userName` has `uniqueness: server` and `caseExact: false`. `externalId`
has `caseExact: true`.
### Authentication and the credential
- **SCIM-7.** Every non-discovery request authenticates with an API key of
the service principal's, sent as `Authorization: Bearer API_…`. Missing,
malformed, unknown, expired or revoked keys answer `401`. HTTP Basic
answers `401` with a `detail` telling the caller to use a bearer token.
**Decision:** other bearer tokens (the server's own OAuth access tokens, or
an external OIDC provider's) are refused on `/scim/v2` with `401`, so that a
person's sign-in token can't drive provisioning. A `401` carries
`WWW-Authenticate: Bearer` (RFC 6750).
- **SCIM-8.** The key is issued the way every API key is today: as an
`x:ApiKey` credential on the service principal's account, created over
JMAP (INBUXA Admin, or ihasmail's administration). The server generates the
secret, returns it once at creation and stores it hashed. The account's
`maxApiKeys` quota applies. Nothing SCIM-specific is added to issuing.
- **SCIM-9.** The key's own controls apply to SCIM like any other use:
`allowedIps` (a request from elsewhere is `401`), `expiresAt`, and its
permission mode (`Inherit`, `Disable`, `Replace`). **Revocation** is
deleting the credential, or disabling or deleting the service principal. It
takes effect on the next request, with no cache delay. **Rotation** is a
second key, the identity provider updated, and the first key deleted.
- **SCIM-10.** **Decision:** OAuth client credentials (RFC 6749 §4.4) aren't
part of this feature. Every client this spec targets can send a static
bearer token: Entra ID's "Secret Token", Okta's "HTTP Header" mode, and the
Keycloak extensions. Adding the grant belongs with the OAuth work in
`contract.md`, and would reuse the same permission checks.
### Authorization
- **SCIM-11.** Two gates, both on the effective permissions of the key (the
account's, cut down by the key's mode, and by the tenant's ceiling, MT-13):
- `authenticate` and `scimAccess` for every non-discovery request. Without
either the answer is `403`, and the `detail` names the missing one.
- Per operation: `sysAccountGet` for every read and query, `.search`
included; `sysAccountCreate` for `POST`; `sysAccountUpdate` for `PUT`,
`PATCH`, and any membership change, including a group created with
members; `sysAccountDestroy` for `DELETE`. Missing: `403`, naming the
permission, with nothing changed.
- **SCIM-12.** A service principal in a tenant needs its tenant to allow
`scimAccess` (MT-13, MT-14). A tenant whose ceiling lacks it can't
provision, whatever its principal is granted.
- **SCIM-13.** A request that would deactivate, delete or rename the service
principal the request authenticated as answers `403`, and changes nothing.
**Decision:** there is no other protected class. Administrators on a
SCIM-enabled domain can be deactivated or deleted by SCIM like anyone, since
a departed administrator is exactly who must be cut off. Operators who want
admins out of the identity provider's reach keep them on a domain that
isn't SCIM-enabled.
- **SCIM-14.** Authenticated HTTP rate limits apply per principal. Over the
limit the answer is `429` with `Retry-After`. `unlimitedRequests` exempts
the principal, as elsewhere. A `/Bulk` request counts as one request.
### Scope: domains and tenants
- **SCIM-15.** **Domain authority.** Every address a write touches (the
`userName`, every alias in `emails`, and a new group's derived address)
must be on a domain with `allowScimProvisioning` true, in the caller's
scope. Otherwise the whole request is refused with `400 invalidValue` and
a `detail` naming the domain. Domains are created by administrators, never
by SCIM. A `userName` that isn't an email address at all is `400
invalidValue`, and its `detail` contains "is not a valid email address".
- **SCIM-16.** **Visibility.** A principal's SCIM scope is every account of
the resource's type that it could reach over JMAP (MT-1, MT-2): all of them
for a server-level principal, its own tenant's for a tenant principal.
**Decision:** SCIM further limits reads, queries and writes to accounts
whose domain has `allowScimProvisioning` true. An identity provider has no
business listing mailboxes it may not manage. Accounts in scope include
ones created by hand (SCIM-36): the service principal and any other account
on the domain appear in `/Users`.
- **SCIM-17.** **Tenant boundaries don't leak.** A resource outside the
caller's tenant answers `404` to `GET`, `PUT`, `PATCH` and `DELETE`, the
same as a resource that doesn't exist, and the same inside `/Bulk`
operations. Filters never match it. An address on a domain in another
tenant (or, for a tenant principal, on a server-level domain) answers
`404`, with a `detail` naming the domain. A domain in the caller's tenant
that isn't SCIM-enabled answers `400 invalidValue` (SCIM-15). Error
`detail` never contains another tenant's data.
- **SCIM-18.** **Where new accounts land.** A new user is in its domain's
tenant (MT-7). A new group is on the service principal's own domain,
because a SCIM Group has no address to take a domain from. So a principal
whose own domain isn't SCIM-enabled can manage users but can't create
groups: `400 invalidValue`, naming its domain.
- **SCIM-19.** **Membership stays inside a tenant.** Adding a member in a
different tenant from the group (no tenant counts as different) is `400
invalidValue` (MT-3).
- **SCIM-20.** **Tenant quotas.** A create that would pass a tenant's
`maxAccounts` or `maxGroups` is refused and emits `limit.tenant-quota`, as
MT-17 requires. **Decision:** SCIM reports it as `403` with a `detail`
naming the limit. RFC 7644 has no quota error type, and `403` is its code
for an operation the caller may not perform.
### User resources
- **SCIM-21.** `id` is the account's registry id as a string, the same value
JMAP uses. It never changes. An identity provider connected to an upstream
Enterprise server keeps working after cutover without matching accounts
again.
- **SCIM-22.** `userName` is the account's full address, `name@domain`. The
server splits it into `name` and `domainId`. It's compared
case-insensitively. **Decision:** it's stored and returned lowercased. A
`userName` already used by any account, alias or list is `409 uniqueness`,
naming the address.
- **SCIM-23.** Changing `userName` (by `PUT` or `PATCH`) moves the account to
the new address. The new domain must pass SCIM-15 and be in the account's
tenant (MT-7), or the change is refused. **Decision:** the old address is
released, not kept as an alias. An identity provider that wants it kept
sends it in `emails`.
- **SCIM-24.** The display name is stored in `description`. Precedence on
write: `displayName`, then `name.formatted`, then `name.givenName` and
`name.familyName` joined by one space (either may be missing). It's
returned under both `displayName` and `name.formatted`, never as
structured parts. With none of them sent, there is no display name, and
neither attribute is returned.
- **SCIM-25.** **Emails.** The primary address always comes first in
`emails`, with `primary: true` and `type: "work"`. It's derived from
`userName`, and its sub-attributes are read-only: trying to remove it,
retype it or make it non-primary through `emails` is `400 mutability`, with
a `detail` pointing at `userName`. Every other entry is an alias,
returned after the primary in stored order with `primary: false`.
**Decision:** aliases are returned without a `type`, and any `type` sent
for them is dropped. Entries that repeat the primary, or each other, are
skipped. Each alias must pass SCIM-15 and be in the account's tenant. An
alias another account already holds is `409 uniqueness`. `PUT` replaces the
alias set. `PATCH` adds or removes aliases one by one, so an address
dropped upstream is dropped here.
- **SCIM-26.** **Locale and time zone.** `locale` and `preferredLanguage`
are one stored value. If both are sent, `locale` wins. Both are returned,
with the same value. SCIM's hyphen form (`en-US`) maps to the stored
underscore form (`en_US`) both ways, case-insensitively, and variants such
as `ca-ES@valencia` are accepted. A locale the server has no translation
for is `400 invalidValue`. `timezone` is an IANA name. An unknown one is
`400 invalidValue`.
- **SCIM-27.** **`active`.** It isn't stored. Reading it gives the account's
effective `authenticate` permission, from the account, its roles and its
tenant.
- Setting `false` adds `authenticate` to the account's disabled
permissions, overriding its roles. If the account's `permissions` was
`Inherit`, it becomes `Merge` with only that entry.
- Setting `true` removes only that entry. An account that was `Inherit`
before goes back to exactly `Inherit`. Every other permission an
administrator set is left as it was.
- If `authenticate` still isn't effective after `true` (a role or the
tenant withholds it), the request succeeds and the response shows
`active: false`. SCIM grants nothing beyond undoing its own suspension.
- `PATCH` accepts JSON booleans and the strings `"true"` and `"false"` in
any case (Entra ID sends `"False"`).
- **SCIM-28.** `groups` on a user is read-only. Each entry has `value` (the
group's id), `display` (the group's display name) and `$ref`. A write to it
is `400 mutability`. Membership is changed through the Group.
- **SCIM-29.** `externalId` is stored exactly as sent and never interpreted.
It's matched case-exactly. An empty string is `400 invalidValue`, as the
schema already requires. **Decision:** within one tenant (or the
server-level scope), two users, or two groups, may not share an
`externalId`. A write that would cause it is `409 uniqueness`. Duplicates
already in stored data are left alone, and a filter on that value returns
them all.
- **SCIM-30.** `meta` has `resourceType`, `created` (the account's
`createdAt`), `location` and `version`. `lastModified` isn't returned,
because accounts don't record one. `location`, `$ref` values and the
`Location` header are absolute URLs built from the server's public URL
(`INBUXA_PUBLIC_URL`).
- **SCIM-31.** **Defaults on create.** A new user gets the `User` role,
`Inherit` permissions (so the server's and tenant's defaults), the server's
default locale, no time zone, and no credentials of any kind. It can sign in
only through the directory that serves its domain, until someone gives it a
password another way.
- **SCIM-32.** **Local settings stay local.** SCIM never reads or writes
quotas, roles, permissions other than the `authenticate` entry of SCIM-27,
credentials, encryption settings, Sieve scripts, mailboxes or any other
field. An administrator's changes to them survive every sync.
- **SCIM-33.** **What is accepted and ignored.** Attributes of the core
RFC 7643 User and Group schemas that aren't in the mapping tables, and
everything under `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`,
are accepted in `POST`, `PUT` and `PATCH` and discarded: never stored, never
returned. That covers `password` (never written to the credential store,
and never echoed in any response), `phoneNumbers`, `addresses`, `photos`,
`ims`, `title`, `userType`, `nickName`, `profileUrl`, `entitlements`,
`roles`, `x509Certificates`, the other `name` parts, and a Group's
`description`. An attribute in no schema the server knows, an unknown
schema URI, a duplicated attribute, or a missing `schemas` value is `400
invalidSyntax`, with a `detail` naming it. This follows RFC 7644 §3.1, not
the interoperability profile's rule that unknown attributes must be
rejected, which is why `interopProfileConformant` is `false`. Attribute
names are case-insensitive (RFC 7643 §2.1).
### Group resources
- **SCIM-34.** `displayName` is required and stored in the group's
`description`. It must be unique among groups in the caller's scope,
compared case-insensitively (**Decision** on case). A clash is `409
uniqueness`.
- **SCIM-35.** **The group's address.** Derived once, at creation, from
`displayName`: lowercased, every run of characters other than ASCII
letters and digits replaced by one hyphen, leading and trailing hyphens
trimmed, and cut to 64 characters, so `Sales EMEA` gives `sales-emea` on the
service principal's domain (SCIM-18). If the address is taken, a numeric
suffix is added until one is free. **Decision** on the details: the
suffix is `-2`, `-3` and so on, the cut to 64 leaves room for it, and an
empty result becomes `group`. If no free address can be found, `409
uniqueness`. Renaming the group later doesn't change its address.
- **SCIM-36.** **Members.** Only users. A member that is a group is `400
invalidValue`. Nested groups aren't supported either way. **Decision:** a
member id that doesn't exist in scope is `400 invalidValue`, naming it.
Membership is stored on each user (`memberGroupIds`), so a membership
change writes each affected user, and needs `sysAccountUpdate`. Each entry
returned has `value`, `display` (the user's display name), `type: "User"`
and `$ref`. Entries are added or removed, never edited in place.
- **SCIM-37.** Reading a group with more than 200 members, without
`excludedAttributes=members`, is `400 tooMany`, and the `detail` says to
exclude `members` and read membership from the users' `groups`.
- **SCIM-38.** A group's `externalId` and `meta` behave as a user's
(SCIM-29, SCIM-30). Its `version` covers its membership, so adding or
removing a member changes it.
### Operations
- **SCIM-39.** **Create** (`POST /Users`, `POST /Groups`): `201`, the full
resource, `Location` and `ETag` headers. The response has the `id` the
identity provider must keep.
- **SCIM-40.** **Read** (`GET /{type}/{id}`): `200` and the resource, with
`ETag`. `attributes` and `excludedAttributes` (RFC 7644 §3.9) work here and
on every query. A resource that doesn't exist or is out of scope is `404`.
- **SCIM-41.** **Replace** (`PUT`): the body is the whole resource. Every
readWrite attribute left out goes back to its default: no display name,
no aliases, the default locale, no time zone, `active` true (undoing only
SCIM's own suspension, SCIM-27), no `externalId`, and for a group no
members. `id` in the body is ignored if it matches, and `400 mutability`
if it doesn't. `200` and the resource.
- **SCIM-42.** **Modify** (`PATCH`, RFC 7644 §3.5.2): a `PatchOp` with
`add`, `remove` and `replace`, op names in any case (Entra ID sends
`Replace`).
- With no `path`, `add` and `replace` take an object of attributes, as
Keycloak's extensions send `{"active": "false"}`.
- Paths may be simple (`displayName`), sub-attributes (`name.givenName`),
fully qualified with a schema URN (enterprise-extension paths are
accepted and discarded, SCIM-33), or value-filtered on a multi-valued
attribute (`emails[value eq "a@b"]`, `members[value eq "id"]`).
- `remove` needs a `path`. `remove` on `members` with no filter removes
every member. **Decision:** removing a member or alias that isn't there
succeeds with no change, because identity providers retry.
- A path the server doesn't support is `400 invalidPath`. A write to a
readOnly attribute is `400 mutability`.
- All the operations in one request apply together or not at all. The
answer is `200` with the full resource, never `204`.
- **SCIM-43.** **Delete** (`DELETE`): `204`. The resource is gone at once:
`GET` answers `404`. What deletion does to mail is SCIM-52.
- **SCIM-44.** **Versions and conditional requests.** Every resource has
`meta.version`, also sent as the `ETag` header. It's computed from the
resource's content, so it changes when the resource does and only then.
**Decision:** it's a weak ETag, `W/"…"`. `If-None-Match` on `GET` answers
`304` when unchanged. `If-Match` on `PUT`, `PATCH` or `DELETE` answers `412`
when the resource has changed. Without the header, writes are
unconditional, and the last one wins. **Decision:** versions may differ
from the ones an upstream server returned, so the first conditional request
after cutover may get `412` and read again. None of the identity providers
covered here send conditional requests.
### Queries
- **SCIM-45.** **Filters** work on `GET /Users`, `GET /Groups` and every
`.search`. The operators are `eq` and `and` only, in any case. Every other
operator (`ne`, `co`, `sw`, `ew`, `gt`, `ge`, `lt`, `le`, `pr`, `or`,
`not`) is `400 invalidFilter`, and the whole filter is parsed first so the
`detail` names the construct. Filterable attributes:
- User: `id`, `externalId`, `userName`, `emails`, `emails.value`,
`active`, `displayName`, `name.formatted`, `groups`, `groups.value`;
- Group: `id`, `externalId`, `displayName`, `members`, `members.value`.
Any other attribute is `400 invalidFilter`. `emails`, `groups` and
`members` without a sub-attribute mean their `value`. A value filter
(`attr[sub eq "x"]`) inside `filter` is `400 invalidFilter`. Comparison
follows each attribute's `caseExact` (SCIM-6). A filter that matches
nothing is `200` with an empty `ListResponse`: that's Entra ID's
connection test.
- **SCIM-46.** `active`, `displayName` and `name.formatted` can't be
answered from an index. They're checked after the indexed part of the
filter has narrowed the candidates. If more than 200 candidates remain, the
request is `400 tooMany`, and the `detail` asks for a narrower filter.
`active eq false` on its own is allowed when it fits under that limit, since
operators use it to find suspended accounts.
- **SCIM-47.** **Sorting.** `sortBy` accepts `id`, and `userName` for users.
`sortOrder` accepts `ascending` and `descending`. Anything else is `400
invalidValue`. **Decision:** with no `sortBy`, results are in ascending
`id` order, so pages are stable.
- **SCIM-48.** **Index pagination** (RFC 7644 §3.4.2.4), the default:
`startIndex` is 1-based, and a value below 1 is treated as 1. `count`
defaults to 100 and is capped at 200. `count=0` returns only
`totalResults`. Responses carry `totalResults`, `startIndex` and
`itemsPerPage`.
- **SCIM-49.** **Cursor pagination** (RFC 9865): `cursor` empty for the first
page, then each response's `nextCursor`. The last page has no
`nextCursor`. `previousCursor` isn't offered. A cursor is opaque and
tamper-evident, and bound to the principal, the filter, the sort and the
`count` that produced it. A cursor presented with any of those changed, or
a forged one, is `400 invalidCursor` (a changed `count` is `400
invalidCount`). **Decision:** a cursor is good for at least 3600 seconds
(advertised as `cursorTimeout`), then `400 expiredCursor`. It needs no
server-side state. Each page is computed with the principal's permissions
at the time, so a permission change can't leak anything through an old
cursor. `startIndex` and `cursor` together are `400 invalidValue`.
- **SCIM-50.** **Query by POST.** `POST /Users/.search` and
`POST /Groups/.search` take a `SearchRequest` with the same parameters.
`POST /.search` searches both types and returns one `ListResponse`.
**Decision:** in the combined result, users come before groups, each in
the requested order, and each resource carries its `schemas` so the client
can tell them apart.
### Bulk
- **SCIM-51.** `POST /Bulk` (RFC 7644 §3.7) takes up to 1000 operations and
1 MiB. More operations, or a larger body, is `413`. It supports
`failOnErrors` and `bulkId`: a later operation may refer to a resource
created earlier in the same request, as `bulkId:<id>` in a `path` or in a
member `value`. Operations run in the order sent. A reference that can't be
resolved, or a circular one, fails that operation with `409 invalidValue`.
Each operation is authorized and scoped exactly as it would be on its own
(SCIM-11, SCIM-17), and reports its own `status` (a string, as RFC 7644
requires), `location` and `version`. Bulk isn't atomic: a failure doesn't
undo earlier successes. The response is `200` unless the request as a
whole is malformed.
### Deprovisioning and mail
- **SCIM-52.** **Suspend and delete are different.**
- **`active: false` (suspend).** The account can't authenticate on any
protocol with any credential: password, app password, API key, OAuth
tokens already issued, and external OIDC tokens. It takes effect on the
next request, without waiting for a permission cache to expire, the way
MT-16 requires for tenant changes. **Decision:** long-lived sessions the
account already has open (IMAP IDLE, JMAP push and event streams,
WebSockets, ManageSieve) are ended. Mail keeps arriving and is kept,
and the account's Sieve rules, forwarding and vacation reply keep
running (**Decision**; mail flow is unchanged by suspension). Its shares
with others stay. It still counts against quotas.
- **`DELETE`.** The account is destroyed through the same path as an
administrator's `x:Account` destroy: the `DestroyAccount` task, shares
other accounts held on it revoked, and its data destroyed. Mail to its
addresses is refused as for an unknown recipient. If undelete's
`archiveDeletedAccountsFor` is set, the account is kept for that period
and its addresses stay reserved (UD-15, UD-16), so the identity provider
re-creating the same `userName` meanwhile gets `409 uniqueness`.
- Without `sysAccountDestroy`, `DELETE` is `403` and the account is left
as it was, typically already suspended. Operators who want suspension
only leave that permission off the key.
- **SCIM-53.** Deleting a group destroys the group account the same way. Its
members lose the membership, and mail to its address is refused as for an
unknown recipient.
- **SCIM-54.** **Decision:** every SCIM write emits an event naming the
service principal, the resource, its `externalId` and the change
(created, updated, suspended, reactivated, deleted), so operators can audit
what the identity provider did. Filter values from query strings aren't
added to any new log field, since RFC 7644 §7.5.2 warns they can carry
personal data.
### Conflict with accounts made another way
- **SCIM-55.** Nothing marks an account as SCIM-owned. Every account in scope
(SCIM-16) can be managed by SCIM, whether SCIM created it, an
administrator did, or just-in-time sync did before the flag was set. An
`externalId` is the only trace SCIM leaves.
- **SCIM-56.** `POST` never adopts an existing account. If the `userName` or
an alias is taken, the answer is `409 uniqueness` naming the address.
Identity providers look for a match first (`filter=userName eq …` or
`externalId eq …`) and then update what they find with `PATCH` or `PUT`,
which is how an account made by hand comes under the identity provider.
- **SCIM-57.** Administrators may still change SCIM-mapped fields over JMAP,
`externalId` included. The next sync may overwrite them. That's expected,
and not an error.
### Just-in-time sync and authority
- **SCIM-58.** When a domain's `allowScimProvisioning` is **true**, SCIM is
authoritative there. Just-in-time directory sync (on sign-in, and on
recipient lookup for LDAP and SQL directories) becomes read-only for that
domain:
- it creates no account. A person who authenticates at the directory before
being provisioned gets an ordinary authentication failure (`401` over
HTTP), not a half-made account;
- it changes nothing on an existing account: not the display name, aliases,
group membership or stored secret. The account's SCIM `version` doesn't
change on sign-in;
- it creates no group from a groups claim;
- the directory still authenticates the person. A provisioned, active
account signs in normally.
- **SCIM-59.** When the flag is **false** (the default), just-in-time sync
works exactly as it does today, and SCIM writes to the domain are refused
(SCIM-15).
- **SCIM-60.** Changing the flag moves nothing. Turning it on leaves existing
accounts where they are; they stop being updated by sync, and the identity
provider takes them over only when it matches them (SCIM-56). Turning it
off leaves SCIM-made accounts in place; sync resumes and may overwrite their
display name and replace their groups on the next sign-in, and SCIM can no
longer see them (SCIM-16). The change takes effect without a restart.
- **SCIM-61.** The rule follows whichever directory serves the domain. Until
per-domain directories (`Domain.directoryId`) are rebuilt, that's the
default directory.
## Interfaces
### Endpoints
All under `/scim/v2`.
| Path | Methods | Authentication |
|---|---|---|
| `/Users` | `GET`, `POST` | API key |
| `/Users/{id}` | `GET`, `PUT`, `PATCH`, `DELETE` | API key |
| `/Users/.search` | `POST` | API key |
| `/Groups` | `GET`, `POST` | API key |
| `/Groups/{id}` | `GET`, `PUT`, `PATCH`, `DELETE` | API key |
| `/Groups/.search` | `POST` | API key |
| `/.search` | `POST` | API key |
| `/Bulk` | `POST` | API key |
| `/ServiceProviderConfig` | `GET` | none |
| `/ResourceTypes`, `/ResourceTypes/{id}` | `GET` | none |
| `/Schemas`, `/Schemas/{urn}` | `GET` | none |
| `/Me` | any | answers `501` |
| any path | `OPTIONS` | none, answers `204` |
### Setting it up
What an operator does, all with existing objects:
1. Set `allowScimProvisioning` on each domain the identity provider may manage.
2. Create a dedicated service-principal account on one of those domains (its
domain is where SCIM groups go, SCIM-18). In a tenant, create it in that
tenant, and make sure the tenant allows `scimAccess` (SCIM-12).
3. Give it an API key. The narrowest is `Replace` mode with `authenticate`,
`scimAccess`, `sysAccountGet`, `sysAccountCreate`, `sysAccountUpdate`, and
`sysAccountDestroy` only if deletion should be honored. Add `allowedIps`
if the identity provider's addresses are known.
4. Give the identity provider the base URL `https://<public host>/scim/v2` and
the key.
The endpoint can be confined further with the existing
`x:Http.allowedEndpoints` expression (for example by remote address or
listener). SCIM adds no setting of its own.
### Errors
SCIM error documents (RFC 7644 §3.12): `schemas`
`["urn:ietf:params:scim:api:messages:2.0:Error"]`, `status` as a string,
`detail`, and `scimType` where one applies.
| Status | `scimType` | When |
|---|---|---|
| `400` | `invalidSyntax` | Malformed JSON; an attribute or schema URI in no known schema; a duplicated attribute; `schemas` missing |
| `400` | `invalidFilter` | Unsupported operator or attribute, or a value filter, in `filter` |
| `400` | `invalidPath` | Unsupported `PATCH` path |
| `400` | `invalidValue` | A value the server can't accept: a domain not open to SCIM, a non-address `userName`, an unknown locale or time zone, a group as a member, a cross-tenant member, an unknown member id, a bad sort, `startIndex` with `cursor` |
| `400` | `mutability` | A write to a readOnly attribute or to the primary email; a mismatched `id` on `PUT` |
| `400` | `tooMany` | Over 200 candidates for an unindexed filter; a group over 200 members read with its members |
| `400` | `invalidCursor`, `expiredCursor`, `invalidCount` | Cursor pagination (RFC 9865) |
| `401` | | No, bad, expired or revoked API key; Basic auth; a bearer token that isn't an API key; a disallowed IP |
| `403` | | A missing permission (named in `detail`); the service principal deactivating, deleting or renaming itself; a tenant limit reached; `filter` on a discovery endpoint |
| `404` | | Unknown resource or path; anything outside the caller's tenant |
| `405` | | Wrong method; `Allow` lists the right ones |
| `409` | `uniqueness` | An address, group name or `externalId` already in use; no free group address |
| `409` | `invalidValue` | An unresolvable or circular `bulkId` (inside a `/Bulk` result) |
| `412` | | `If-Match` no longer holds |
| `413` | | `/Bulk` over 1000 operations or 1 MiB |
| `429` | | Rate limited; `Retry-After` says when to retry |
| `501` | | `/Me` |
### JMAP
- **Existing, unchanged:** `x:Domain.allowScimProvisioning`,
`x:UserAccount.externalId` and `x:GroupAccount.externalId` through
`x:Domain` and `x:Account` `/get`, `/set` and `/query`, as ordinary fields;
the `scimAccess` permission; `x:ApiKey` credentials.
- **New, Decision:** `x:Account/query` accepts an `externalId` filter (the
index already exists), so administration screens can find the account an
identity provider means.
## ihasmail changes
These go in ihasmail-inbuxa, not public ihasmail, which stays
Stalwart-facing (SPEC.md §5).
- **Domains:** an "Allow SCIM provisioning" switch on the domain form. Turning
it on warns that sign-in sync stops creating and updating accounts on the
domain (SCIM-58), and turning it off warns that sync resumes (SCIM-60).
- **Accounts and groups:** show `externalId` when set, as "Managed by the
identity provider", and say that edits to the name, display name, aliases,
groups or sign-in status may be overwritten at the next sync (SCIM-57).
Allow clearing it.
- **Suspended accounts:** show an account whose `authenticate` permission is
disabled as suspended, with a filter for them, so operators can review
what Okta leaves behind (it never deletes). Reactivating from ihasmail
undoes the suspension the same way SCIM-27 does.
- **Service principal and key:** creating an API key with the SCIM
permissions stays in INBUXA Admin. ihasmail links there from the domain
switch rather than growing its own screen (SPEC.md §5.4).
- Every string this adds is new translation work for ihasmail's nine
languages.
## Acceptance tests
Every test runs against inbuxa-server built with no Enterprise code. The three
surviving suites come back first: rebuild `tests/src/scim/mod.rs` with the
helpers they import (from this spec, not from the removed file), put
`pub mod scim;` back in `tests/src/lib.rs`, and re-export the constants they
use from `scim-proto`. `oidc.rs` also needs per-domain directories (see open
questions). The rest are new, written from this spec to cover what the
removed suites (`auth`, `bulk`, `discovery`, `groups`, `limits`, `query`,
`users`) covered.
1. **Third-party lifecycle** (`conformance.rs`, `lifecycle`): discovery
values, create user with an alias, read by id and by filter, `.search`,
`PATCH` display name and `active`, `PUT` that resets `active` and the
display name, group created with a member, membership shown on the user,
members removed, group and user deleted, user `404` (SCIM-4, SCIM-22,
SCIM-24, SCIM-25, SCIM-27, SCIM-28, SCIM-30, SCIM-36, SCIM-39 to SCIM-44,
SCIM-50).
2. **Real client payloads** (`conformance.rs`, `clients`): Okta create with
`password` and extra attributes, Keycloak create with only structured
names, Entra create with the enterprise extension, Okta `PUT`, Keycloak
no-path `PATCH` with `"false"`, Entra `Replace` with extension path,
lookup by `userName`, and `dispalyName` refused `invalidSyntax`. No
response contains `password` (SCIM-24, SCIM-27, SCIM-33, SCIM-42).
3. **Conformance checker** (`conformance.rs`): `scim2-tester` reports no
error, critical or deviation beyond the generated non-address `userName`
(SCIM-1 to SCIM-6, SCIM-15).
4. **Tenant isolation** (`tenant.rs`): a tenant client lists only its two
accounts, filters and `.search` never find an outsider, every operation
on an outsider is `404` (in `/Bulk` too), adding an outsider to a group is
`400 invalidValue`, an address on a server-level domain is `404` naming
it, and provisioning inside its own domain works end to end (SCIM-16,
SCIM-17, SCIM-19, SCIM-51).
5. **OIDC authority** (`oidc.rs`): with the flag on, an unprovisioned
Keycloak user's sign-in is `401` and creates nothing; a SCIM user signs in,
and its display name, groups and `version` are unchanged after two
sign-ins; no group is created from the claim. With the flag off, sign-in
creates the account and its claimed group, and replaces the SCIM name and
groups (SCIM-58 to SCIM-60).
6. Basic auth, a missing token, an OAuth access token, an expired key, a
deleted key and a key from a disallowed IP: all `401`. A `Replace` key
without `scimAccess`: `403` naming it. Deleting the key: the next request
is `401` (SCIM-7, SCIM-9, SCIM-11).
7. A key without `sysAccountDestroy`: `DELETE` is `403`, `PATCH active=false`
works (SCIM-11, SCIM-52).
8. The service principal deactivating, deleting or renaming itself: `403`,
unchanged (SCIM-13).
9. A tenant whose ceiling lacks `scimAccess`: its principal is refused even
with the permission on its account and key (SCIM-12).
10. Discovery without auth: `200`. `filter` on `/Schemas`: `403`. Unknown
path `404`, wrong method `405` with `Allow`, `OPTIONS` `204`, `/Me` `501`
(SCIM-2, SCIM-3).
11. `userName` on a domain with the flag off: `400 invalidValue` naming it.
An alias on such a domain in an otherwise valid create: the whole create
refused (SCIM-15).
12. Accounts on a domain without the flag don't appear in `/Users` (SCIM-16).
13. Group created by a principal whose domain isn't SCIM-enabled: `400
invalidValue` (SCIM-18).
14. `maxAccounts` reached in a tenant: `403` naming the limit, and
`limit.tenant-quota` emitted (SCIM-20).
15. Rename by `userName`: the account moves, the old address is refused as
unknown, the id is unchanged (SCIM-21, SCIM-23).
16. Duplicate address on create: `409 uniqueness`, and the existing account
is untouched (SCIM-22, SCIM-56).
17. Emails: the primary first and read-only, a duplicate alias skipped, an
alias dropped by `PATCH remove` gone, an alias another account holds `409`
(SCIM-25).
18. `EN-us`, `ca-ES@valencia` and `preferredLanguage` alone all store and
read back; `xx-YY` and `Mars/Olympus` are `400 invalidValue` (SCIM-26).
19. `active` round trip on an `Inherit` account leaves `permissions` exactly
`Inherit`; on an account with custom permissions, leaves them as they
were. An account whose role lacks `authenticate` reads `active: false`
(SCIM-27).
20. Same `externalId` on two users in one tenant: `409`. In two tenants:
allowed. Filter on it: case-exact (SCIM-29).
21. Groups: `displayName` clash in any case `409`; `Sales EMEA` gives
`sales-emea`, a second gives `sales-emea-2`; a rename keeps the address; a
group as member `400`; 201 members read without exclusion `400 tooMany`;
the version changes when a member is added (SCIM-34 to SCIM-38).
22. `PATCH` atomicity: one bad operation among good ones changes nothing.
Removing a non-member succeeds (SCIM-42).
23. `If-None-Match` gives `304`; a stale `If-Match` gives `412` on `PUT`,
`PATCH` and `DELETE` (SCIM-44).
24. Filters: each supported attribute answers; `co`, `or`, `pr` are `400
invalidFilter`; an unfiltered `active eq false` over 200 candidates is
`400 tooMany` (SCIM-45, SCIM-46).
25. Sorting and index pages: `count` 500 gives 200; `count=0` gives totals
only; pages in stable `id` order (SCIM-47, SCIM-48).
26. Cursors: walk 450 users in pages of 200 to the end; a cursor with a
changed filter `invalidCursor`, changed count `invalidCount`, a
tampered cursor `invalidCursor` (SCIM-49).
27. Bulk: create a user and a group that references it by `bulkId`;
`failOnErrors: 1` stops after the first failure; 1001 operations `413`
(SCIM-51).
28. Suspend: IMAP login, JMAP with an existing OAuth token, and an app
password all fail on the next attempt; an open IMAP IDLE is closed; mail
to the account is still delivered (SCIM-52).
29. Delete: mail to the address is refused as unknown; with
`archiveDeletedAccountsFor` set, re-creating the same `userName` is `409`
until the hold ends (SCIM-52).
30. An account created by an administrator is found by `userName` filter,
adopted by `PATCH` setting `externalId`, and its quota and roles are
unchanged (SCIM-32, SCIM-55, SCIM-56).
31. **(compat)** On a copy of INBUXA's data: every `externalId` and flag
reads back unchanged, and the SCIM ids equal the account ids JMAP
returns (SCIM-21).
## Open questions / to observe
1. **Per-domain directories.** `x:Domain.directoryId` is flagged Enterprise,
isn't rebuilt (`get_directory_for_domain` returns the default), and no
feature in SPEC.md §4 claims it. `oidc.rs` needs it. It needs a home: its
own small spec, or a section of this one.
2. **Does INBUXA use SCIM today?** If any identity provider provisions into
the live server, cutover must keep it working (SCIM-21, SCIM-44), and
that should be checked on a copy before cutover, not assumed.
3. **Visibility of accounts on domains without the flag** (SCIM-16). The
public docs don't say whether upstream lists them. Observe, and decide
whether matching upstream matters to any client.
4. **`externalId` uniqueness** (SCIM-29) is our Decision. The index isn't
unique and the docs are silent.
5. **`userName` case** (SCIM-22). Whether upstream returns the case sent or
a lowercased form. Strict clients compare it.
6. **Old address on rename** (SCIM-23), **alias `type`** (SCIM-25),
**suffix format and empty slugs** (SCIM-35), **unknown member ids**
(SCIM-36), **removing a non-member** (SCIM-42), **combined `.search`
order** (SCIM-50): details the docs don't settle, decided here.
7. **`active: true` when a role withholds `authenticate`** (SCIM-27). Our
answer is to succeed and report `false`. Some identity providers may loop
on that. Check Entra ID's and Okta's handling.
8. **Suspended accounts' Sieve, forwarding and vacation** (SCIM-52), and
**ending open sessions**: whether upstream does either.
9. **Other bearer tokens on `/scim/v2`** (SCIM-7). Upstream's docs name only
API keys. Whether it also accepts OAuth access tokens is unknown; we
refuse them.
10. **OAuth client credentials** (SCIM-10). Entra ID gallery apps and Okta's
OAuth mode would want it. Decide with `contract.md`'s OAuth work.
11. **Tenant quota status** (SCIM-20) and the rate-limit accounting of
`/Bulk` (SCIM-14) are our Decisions.
12. **The interop and IPSIE profiles.** Upstream's docs say it follows
draft-zollner-scim-interop-profile (except unknown-attribute rejection)
and the IPSIE profile's lifecycle rules. Neither draft was read for this
spec. Read both and add any requirement they impose that isn't here.
13. **Vendor documentation.** Microsoft's, Okta's and the Keycloak
extensions' own SCIM documentation wasn't read. Check them against
SCIM-7, SCIM-27, SCIM-42 and SCIM-45 before implementation.
14. **`SCIM_DOMAIN` and `HTTP_PORT` in the test helpers.** The surviving
tests imply `scim.example.com` and port 8899 (from addresses and URLs in
them). Confirm when rebuilding `mod.rs`.