Feature specs 4-9: branding and templates, AI spam classification, monitoring, SCIM, scale-out storage, per-domain directories
This commit is contained in:
@@ -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.0–1.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.0–1.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.0–1.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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user