Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
# Stalwart – Test Infrastructure
|
||||
|
||||
Ephemeral Docker Compose stack for testing Stalwart against external services.
|
||||
All data is lost on `docker compose down` – every restart is a clean slate.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd stalwart-test
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Wait ~30 seconds for all services to initialize (Keycloak takes the longest).
|
||||
|
||||
## Connection Reference
|
||||
|
||||
| Service | Host | Port(s) | Credentials / Notes |
|
||||
|----------------|-------------------|-----------------|--------------------------------------------|
|
||||
| PostgreSQL | localhost | 5432 | `stalwart` / `stalwart`, db: `stalwart` |
|
||||
| MySQL | localhost | 3306 | `stalwart` / `stalwart`, db: `stalwart` |
|
||||
| FoundationDB | localhost | 4500 | Cluster file from container |
|
||||
| Redis | localhost | 6379 | No auth |
|
||||
| OpenSearch | localhost | 9200 | No auth, security plugin disabled |
|
||||
| Meilisearch | localhost | 7700 | Master key: `stalwart-master-key` |
|
||||
| MinIO (S3) | localhost | 9000 / 9001 | `minioadmin` / `minioadmin`, bucket: `stalwart` |
|
||||
| Keycloak (OIDC)| localhost | 9080 | Admin: `admin` / `admin` |
|
||||
| OpenLDAP | localhost | 389 / 636 (TLS) | Admin DN: `cn=admin,dc=stalwart,dc=test`, pw: `admin` |
|
||||
| Pebble (ACME) | localhost | 14000 / 15000 | Self-signed TLS, uses challtestsrv |
|
||||
| Challtestsrv | localhost | 8055 | ACME challenge test server (management API)|
|
||||
| PowerDNS | localhost | 5300 / 8081 | API key: `stalwart-api-key` |
|
||||
| NATS | localhost | 4222 / 8222 | No auth |
|
||||
|
||||
## OIDC (Keycloak) Details
|
||||
|
||||
- **OIDC Discovery**: `http://localhost:9080/realms/stalwart/.well-known/openid-configuration`
|
||||
- **Token Endpoint**: `http://localhost:9080/realms/stalwart/protocol/openid-connect/token`
|
||||
- **Client ID**: `stalwart`
|
||||
- **Client Secret**: `stalwart-secret`
|
||||
- **Realm**: `stalwart`
|
||||
|
||||
### Test Users
|
||||
|
||||
| Username | Password | Groups |
|
||||
|---------------------------|--------------------------|----------------------------------------|
|
||||
| john.doe@example.org | this is an OIDC password | sales@example.org |
|
||||
| jane.smith@example.org | this is an OIDC password | sales@example.org, corporate@example.org |
|
||||
| bill.foobar@example.org | this is an OIDC password | corporate@example.org |
|
||||
|
||||
### Example: Get a Token
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9080/realms/stalwart/protocol/openid-connect/token \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=stalwart" \
|
||||
-d "client_secret=stalwart-secret" \
|
||||
-d "[email protected]" \
|
||||
-d "password=this is an OIDC password"
|
||||
```
|
||||
|
||||
## LDAP Details
|
||||
|
||||
- **Base DN**: `dc=stalwart,dc=test`
|
||||
- **Admin DN**: `cn=admin,dc=stalwart,dc=test`
|
||||
- **Admin Password**: `admin`
|
||||
- **Read-only DN**: `cn=readonly,dc=stalwart,dc=test`
|
||||
- **Read-only Password**: `readonly`
|
||||
- **User DN pattern**: `uid={username},ou=users,dc=stalwart,dc=test`
|
||||
|
||||
### Test Users
|
||||
|
||||
| DN | Mail | Password |
|
||||
|------------------------------------------------|--------------------------|--------------------------|
|
||||
| uid=john.doe,ou=users,dc=stalwart,dc=test | john.doe@example.org | this is an LDAP password |
|
||||
| uid=jane.smith,ou=users,dc=stalwart,dc=test | jane.smith@example.org | this is an LDAP password |
|
||||
| uid=bill.foobar,ou=users,dc=stalwart,dc=test | bill.foobar@example.org | this is an LDAP password |
|
||||
|
||||
### Groups
|
||||
|
||||
| DN | Mail | Members |
|
||||
|---------------------------------------------|-------------------------|------------------|
|
||||
| cn=sales,ou=groups,dc=stalwart,dc=test | sales@example.org | john.doe, jane.smith |
|
||||
| cn=corporate,ou=groups,dc=stalwart,dc=test | corporate@example.org | bill.foobar, jane.smith |
|
||||
|
||||
### Example: Search by Email
|
||||
|
||||
```bash
|
||||
ldapsearch -x -H ldap://localhost:389 \
|
||||
-D "cn=admin,dc=stalwart,dc=test" -w admin \
|
||||
-b "dc=stalwart,dc=test" "([email protected])"
|
||||
```
|
||||
|
||||
## S3 (MinIO) Details
|
||||
|
||||
- **Endpoint**: `http://localhost:9000`
|
||||
- **Access Key**: `minioadmin`
|
||||
- **Secret Key**: `minioadmin`
|
||||
- **Bucket**: `stalwart`
|
||||
- **Console**: `http://localhost:9001`
|
||||
- **Region**: `us-east-1` (MinIO default)
|
||||
|
||||
## DNS (PowerDNS) Details
|
||||
|
||||
- **DNS port**: 5300 (TCP+UDP)
|
||||
- **API**: `http://localhost:8081` (API key: `stalwart-api-key`)
|
||||
- **Zone**: `stalwart.test`
|
||||
- **TSIG key name**: `stalwart-update-key`
|
||||
- **TSIG algorithm**: `hmac-sha256`
|
||||
- **TSIG secret (base64)**: `c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw`
|
||||
|
||||
> **Note on SIG(0):** PowerDNS does not support SIG(0) authentication for RFC2136
|
||||
> updates. Only BIND has (limited) SIG(0) support. If you need to test SIG(0),
|
||||
> a separate BIND instance would be required.
|
||||
|
||||
### Example: Query TLSA Record
|
||||
|
||||
```bash
|
||||
dig @localhost -p 5300 _25._tcp.mail.stalwart.test TLSA
|
||||
```
|
||||
|
||||
### Example: RFC2136 Dynamic Update
|
||||
|
||||
```bash
|
||||
nsupdate -y hmac-sha256:stalwart-update-key:c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw <<EOF
|
||||
server 127.0.0.1 5300
|
||||
zone stalwart.test
|
||||
update add test.stalwart.test 300 A 192.168.1.100
|
||||
send
|
||||
EOF
|
||||
```
|
||||
|
||||
## ACME (Pebble + Challenge Test Server) Details
|
||||
|
||||
- **Directory URL**: `https://localhost:14000/dir`
|
||||
- **Pebble Management URL**: `https://localhost:15000`
|
||||
- **Challenge Test Server API**: `http://localhost:8055`
|
||||
- **TLS**: Self-signed — Stalwart must trust the Pebble CA or skip TLS verification
|
||||
- Pebble uses the challenge test server (`pebble-challtestsrv`) as its DNS resolver,
|
||||
so challenge validation goes through controllable DNS/HTTP/TLS-ALPN responders.
|
||||
|
||||
### Challenge Test Server (challtestsrv)
|
||||
|
||||
The challenge test server provides a management API on port 8055 to programmatically
|
||||
control DNS records and challenge responses used during ACME validation.
|
||||
|
||||
**Default behavior**: All A/AAAA queries resolve to `host.docker.internal` (the Docker
|
||||
host), so Pebble can reach your test server on localhost automatically. Tests only need
|
||||
to add challenge-specific records (TXT for DNS-01, HTTP tokens, etc.).
|
||||
|
||||
### How Pebble Reaches Your Test Server
|
||||
|
||||
When Pebble validates an HTTP-01 or TLS-ALPN-01 challenge, it:
|
||||
|
||||
1. Resolves the domain via challtestsrv — by default all domains resolve to the Docker host
|
||||
2. Connects to the resolved IP on port **5002** (HTTP-01) or **5001** (TLS-ALPN-01)
|
||||
|
||||
These ports are configured in `pebble/pebble-config.json` (`httpPort` / `tlsPort`).
|
||||
Change them to match whatever port your test Stalwart instance listens on.
|
||||
|
||||
#### Management API Examples
|
||||
|
||||
```bash
|
||||
# Add a DNS-01 TXT challenge response
|
||||
curl -s -X POST http://localhost:8055/add-dns \
|
||||
-d '{"host": "_acme-challenge.mail.stalwart.test.", "value": "dns-challenge-token"}'
|
||||
|
||||
# Remove a DNS-01 TXT challenge response
|
||||
curl -s -X POST http://localhost:8055/del-dns \
|
||||
-d '{"host": "_acme-challenge.mail.stalwart.test."}'
|
||||
|
||||
# Add an HTTP-01 challenge response (served by challtestsrv itself)
|
||||
curl -s -X POST http://localhost:8055/add-http \
|
||||
-d '{"token": "challenge-token", "content": "challenge-key-authorization"}'
|
||||
|
||||
# Remove an HTTP-01 challenge response
|
||||
curl -s -X POST http://localhost:8055/del-http \
|
||||
-d '{"token": "challenge-token"}'
|
||||
|
||||
# Add a TLS-ALPN-01 challenge response (served by challtestsrv itself)
|
||||
curl -s -X POST http://localhost:8055/add-tlsalpn \
|
||||
-d '{"host": "mail.stalwart.test", "content": "base64-encoded-key-authz"}'
|
||||
|
||||
# Remove a TLS-ALPN-01 challenge response
|
||||
curl -s -X POST http://localhost:8055/del-tlsalpn \
|
||||
-d '{"host": "mail.stalwart.test"}'
|
||||
|
||||
# Clear all mock DNS/challenge data
|
||||
curl -s -X POST http://localhost:8055/clear-request-count
|
||||
```
|
||||
|
||||
## Self-Signed TLS Certificate
|
||||
|
||||
A shared self-signed certificate is generated at startup and mounted into services
|
||||
that need it. The cert is valid for:
|
||||
- `localhost`, `keycloak`, `openldap`, `pebble`, `*.stalwart.test`, `127.0.0.1`
|
||||
|
||||
To extract the cert for use with Stalwart:
|
||||
|
||||
```bash
|
||||
docker compose cp cert-init:/certs/cert.pem ./test-cert.pem
|
||||
docker compose cp cert-init:/certs/key.pem ./test-key.pem
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
# Check all services are running
|
||||
docker compose ps
|
||||
|
||||
# View logs for a specific service
|
||||
docker compose logs -f keycloak
|
||||
|
||||
# Restart everything fresh
|
||||
docker compose down && docker compose up -d
|
||||
|
||||
# Check FoundationDB status
|
||||
docker compose exec foundationdb fdbcli --exec "status"
|
||||
|
||||
# Verify TSIG key is loaded
|
||||
docker compose exec powerdns pdnsutil list-tsig-keys
|
||||
```
|
||||
@@ -0,0 +1,276 @@
|
||||
###############################################################################
|
||||
# Stalwart – Test Infrastructure
|
||||
# All services are ephemeral (no volumes = fresh on every restart)
|
||||
# Ports exposed on localhost; 80/443/8080 are remapped
|
||||
###############################################################################
|
||||
|
||||
services:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Init: generate shared self-signed TLS certificates
|
||||
# ---------------------------------------------------------------------------
|
||||
cert-init:
|
||||
image: alpine/openssl:latest
|
||||
volumes:
|
||||
- certs:/certs
|
||||
- ./scripts/gen-certs.sh:/gen-certs.sh:ro
|
||||
entrypoint: [ "sh", "/gen-certs.sh" ]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL
|
||||
# ---------------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: stalwart
|
||||
POSTGRES_PASSWORD: stalwart
|
||||
POSTGRES_DB: stalwart
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MySQL
|
||||
# ---------------------------------------------------------------------------
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: stalwart
|
||||
MYSQL_DATABASE: stalwart
|
||||
MYSQL_USER: stalwart
|
||||
MYSQL_PASSWORD: stalwart
|
||||
ports:
|
||||
- "127.0.0.1:3306:3306"
|
||||
tmpfs:
|
||||
- /var/lib/mysql
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FoundationDB
|
||||
# ---------------------------------------------------------------------------
|
||||
foundationdb:
|
||||
image: foundationdb/foundationdb:7.4.6
|
||||
ports:
|
||||
- "127.0.0.1:4500:4500"
|
||||
environment:
|
||||
FDB_NETWORKING_MODE: container
|
||||
volumes:
|
||||
- fdb-config:/var/fdb
|
||||
tmpfs:
|
||||
- /var/fdb/data
|
||||
- /var/fdb/logs
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "fdbcli --exec 'status' --timeout 3 >/dev/null 2>&1" ]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
fdb-init:
|
||||
image: foundationdb/foundationdb:7.4.6
|
||||
depends_on:
|
||||
foundationdb:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- fdb-config:/var/fdb
|
||||
- ./scripts/init-fdb.sh:/init-fdb.sh:ro
|
||||
entrypoint: [ "bash", "/init-fdb.sh" ]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
command: redis-server --save "" --appendonly no
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenSearch (ElasticSearch-compatible)
|
||||
# ---------------------------------------------------------------------------
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2
|
||||
environment:
|
||||
discovery.type: single-node
|
||||
DISABLE_SECURITY_PLUGIN: "true"
|
||||
OPENSEARCH_JAVA_OPTS: "-Xms256m -Xmx256m"
|
||||
DISABLE_INSTALL_DEMO_CONFIG: "true"
|
||||
ports:
|
||||
- "127.0.0.1:9200:9200"
|
||||
tmpfs:
|
||||
- /usr/share/opensearch/data:uid=1000,gid=1000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Meilisearch
|
||||
# ---------------------------------------------------------------------------
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:latest
|
||||
environment:
|
||||
MEILI_ENV: development
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
MEILI_MASTER_KEY: stalwart-master-key
|
||||
ports:
|
||||
- "127.0.0.1:7700:7700"
|
||||
tmpfs:
|
||||
- /meili_data
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MinIO (S3-compatible)
|
||||
# ---------------------------------------------------------------------------
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000"
|
||||
- "127.0.0.1:9001:9001"
|
||||
command: server /data --console-address ":9001"
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
- minio
|
||||
volumes:
|
||||
- ./scripts/init-minio.sh:/init-minio.sh:ro
|
||||
entrypoint: [ "bash", "/init-minio.sh" ]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keycloak (OIDC Provider) – port 9080 (moved from 8080)
|
||||
# ---------------------------------------------------------------------------
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
depends_on:
|
||||
cert-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KC_HTTP_PORT: 9080
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
ports:
|
||||
- "127.0.0.1:9080:9080"
|
||||
volumes:
|
||||
- ./keycloak/stalwart-realm.json:/opt/keycloak/data/import/stalwart-realm.json:ro
|
||||
- certs:/certs:ro
|
||||
command: >
|
||||
start-dev --import-realm
|
||||
tmpfs:
|
||||
- /opt/keycloak/data:uid=1000,gid=1000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenLDAP
|
||||
# ---------------------------------------------------------------------------
|
||||
openldap:
|
||||
image: osixia/openldap:1.5.0
|
||||
depends_on:
|
||||
cert-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
LDAP_ORGANISATION: "Stalwart Test"
|
||||
LDAP_DOMAIN: "stalwart.test"
|
||||
LDAP_BASE_DN: "dc=stalwart,dc=test"
|
||||
LDAP_ADMIN_PASSWORD: "admin"
|
||||
LDAP_READONLY_USER: "true"
|
||||
LDAP_READONLY_USER_USERNAME: "readonly"
|
||||
LDAP_READONLY_USER_PASSWORD: "readonly"
|
||||
LDAP_TLS: "true"
|
||||
LDAP_TLS_CRT_FILENAME: "cert.pem"
|
||||
LDAP_TLS_KEY_FILENAME: "key.pem"
|
||||
LDAP_TLS_CA_CRT_FILENAME: "cert.pem"
|
||||
LDAP_TLS_VERIFY_CLIENT: "never"
|
||||
ports:
|
||||
- "127.0.0.1:389:389"
|
||||
- "127.0.0.1:636:636"
|
||||
volumes:
|
||||
- ./ldap/50-users.ldif:/seed/50-users.ldif:ro
|
||||
- ./ldap/60-groups.ldif:/seed/60-groups.ldif:ro
|
||||
- certs:/certs-shared:ro
|
||||
entrypoint: [ "/bin/bash", "-c", "mkdir -p /container/service/slapd/assets/config/bootstrap/ldif/custom && cp /seed/*.ldif /container/service/slapd/assets/config/bootstrap/ldif/custom/ && cp /certs-shared/* /container/service/slapd/assets/certs/ 2>/dev/null; exec /container/tool/run" ]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pebble Challenge Test Server – port 8055 (management API)
|
||||
# Provides controllable DNS, HTTP, and TLS-ALPN challenge responders.
|
||||
# Use the management API to add/remove challenge responses before requesting
|
||||
# certificates from Pebble.
|
||||
# ---------------------------------------------------------------------------
|
||||
pebble-challtestsrv:
|
||||
build:
|
||||
context: ./pebble
|
||||
dockerfile: Dockerfile.challtestsrv
|
||||
image: stalwart-pebble-challtestsrv:local
|
||||
ports:
|
||||
- "127.0.0.1:8055:8055"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pebble (ACME server) – ports 14000 (directory) + 15000 (management)
|
||||
# ---------------------------------------------------------------------------
|
||||
pebble:
|
||||
image: ghcr.io/letsencrypt/pebble:latest
|
||||
depends_on:
|
||||
- pebble-challtestsrv
|
||||
environment:
|
||||
PEBBLE_VA_NOSLEEP: "1"
|
||||
PEBBLE_WFE_NONCEREJECT: "0"
|
||||
ports:
|
||||
- "127.0.0.1:14000:14000"
|
||||
- "127.0.0.1:15000:15000"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./pebble/pebble-config.json:/test/config/pebble-config.json:ro
|
||||
command: -config /test/config/pebble-config.json -dnsserver pebble-challtestsrv:8053
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PowerDNS (DNS with TLSA + RFC2136) – port 5300 (moved from 53)
|
||||
# ---------------------------------------------------------------------------
|
||||
powerdns:
|
||||
image: powerdns/pdns-auth-49:latest
|
||||
environment:
|
||||
PDNS_AUTH_API_KEY: stalwart-api-key
|
||||
ports:
|
||||
- "127.0.0.1:5300:53/tcp"
|
||||
- "127.0.0.1:5300:53/udp"
|
||||
- "127.0.0.1:8081:8081"
|
||||
volumes:
|
||||
- ./powerdns/pdns.conf:/etc/powerdns/pdns.d/stalwart.conf:ro
|
||||
- ./powerdns/init-zone.sh:/etc/powerdns/init-zone.sh:ro
|
||||
- pdns-data:/var/lib/powerdns
|
||||
|
||||
powerdns-init:
|
||||
image: powerdns/pdns-auth-49:latest
|
||||
depends_on:
|
||||
- powerdns
|
||||
volumes:
|
||||
- ./powerdns/entrypoint.sh:/init.sh:ro
|
||||
- ./powerdns/init-zone.sh:/etc/powerdns/init-zone.sh:ro
|
||||
- pdns-data:/var/lib/powerdns
|
||||
entrypoint: [ "bash", "/init.sh" ]
|
||||
network_mode: "service:powerdns"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NATS (message queue, core mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
nats:
|
||||
image: nats:latest
|
||||
ports:
|
||||
- "127.0.0.1:4222:4222"
|
||||
- "127.0.0.1:8222:8222"
|
||||
command: "--addr 0.0.0.0 --port 4222 --http_port 8222"
|
||||
|
||||
# =============================================================================
|
||||
# Shared volumes (ephemeral – docker compose down removes them)
|
||||
# =============================================================================
|
||||
volumes:
|
||||
certs:
|
||||
driver: local
|
||||
fdb-config:
|
||||
driver: local
|
||||
pdns-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"realm": "stalwart",
|
||||
"enabled": true,
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"sslRequired": "none",
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "stalwart",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "stalwart-secret",
|
||||
"redirectUris": [
|
||||
"*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"*"
|
||||
],
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"directAccessGrantsEnabled": true,
|
||||
"standardFlowEnabled": true,
|
||||
"serviceAccountsEnabled": true,
|
||||
"defaultClientScopes": [
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
"roles"
|
||||
],
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "groups",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-group-membership-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"full.path": "false",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "groups",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email-claim",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "email",
|
||||
"userinfo.token.claim": "true",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "stalwart",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "stalwart-fallback",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "stalwart-fallback-secret",
|
||||
"redirectUris": [
|
||||
"*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"*"
|
||||
],
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"directAccessGrantsEnabled": true,
|
||||
"standardFlowEnabled": true,
|
||||
"serviceAccountsEnabled": true,
|
||||
"defaultClientScopes": [
|
||||
"openid"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"email",
|
||||
"profile",
|
||||
"roles"
|
||||
],
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "email-claim-userinfo-only",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"user.attribute": "email",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "false",
|
||||
"claim.name": "email",
|
||||
"userinfo.token.claim": "true",
|
||||
"jsonType.label": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "groups-userinfo-only",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-group-membership-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"full.path": "false",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "false",
|
||||
"claim.name": "groups",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "stalwart",
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"username": "[email protected]",
|
||||
"enabled": true,
|
||||
"email": "[email protected]",
|
||||
"emailVerified": true,
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "this is an OIDC password",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
"/[email protected]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "[email protected]",
|
||||
"enabled": true,
|
||||
"email": "[email protected]",
|
||||
"emailVerified": true,
|
||||
"firstName": "Jane",
|
||||
"lastName": "Smith",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "this is an OIDC password",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
"/[email protected]",
|
||||
"/[email protected]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "[email protected]",
|
||||
"enabled": true,
|
||||
"email": "[email protected]",
|
||||
"emailVerified": true,
|
||||
"firstName": "Bill",
|
||||
"lastName": "Foobar",
|
||||
"credentials": [
|
||||
{
|
||||
"type": "password",
|
||||
"value": "this is an OIDC password",
|
||||
"temporary": false
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
"/[email protected]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"name": "[email protected]",
|
||||
"path": "/[email protected]"
|
||||
},
|
||||
{
|
||||
"name": "[email protected]",
|
||||
"path": "/[email protected]"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
dn: ou=users,dc=stalwart,dc=test
|
||||
objectClass: organizationalUnit
|
||||
ou: users
|
||||
|
||||
dn: uid=john.doe,ou=users,dc=stalwart,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: shadowAccount
|
||||
objectClass: extensibleObject
|
||||
uid: john.doe
|
||||
cn: John Doe
|
||||
sn: Doe
|
||||
givenName: John
|
||||
mail: [email protected]
|
||||
mailAlias: [email protected]
|
||||
userPassword: this is John's LDAP password
|
||||
shadowLastChange: 19723
|
||||
uidNumber: 10001
|
||||
gidNumber: 10001
|
||||
homeDirectory: /home/john.doe
|
||||
loginShell: /bin/bash
|
||||
|
||||
dn: uid=jane.smith,ou=users,dc=stalwart,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: shadowAccount
|
||||
uid: jane.smith
|
||||
cn: Jane Smith
|
||||
sn: Smith
|
||||
givenName: Jane
|
||||
mail: [email protected]
|
||||
userPassword: this is Jane's LDAP password
|
||||
shadowLastChange: 19724
|
||||
uidNumber: 10002
|
||||
gidNumber: 10002
|
||||
homeDirectory: /home/jane.smith
|
||||
loginShell: /bin/bash
|
||||
|
||||
dn: uid=multi.mail,ou=users,dc=stalwart,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: shadowAccount
|
||||
uid: multi.mail
|
||||
cn: Multi Mail
|
||||
sn: Mail
|
||||
givenName: Multi
|
||||
mail: [email protected]
|
||||
mail: [email protected]
|
||||
mail: [email protected]
|
||||
userPassword: this is Multi's LDAP password
|
||||
uidNumber: 10004
|
||||
gidNumber: 10004
|
||||
homeDirectory: /home/multi.mail
|
||||
loginShell: /bin/bash
|
||||
|
||||
dn: uid=bill.foobar,ou=users,dc=stalwart,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: shadowAccount
|
||||
objectClass: extensibleObject
|
||||
uid: bill.foobar
|
||||
cn: Bill Foobar
|
||||
sn: Foobar
|
||||
givenName: Bill
|
||||
mail: [email protected]
|
||||
mailAlias: [email protected]
|
||||
userPassword: this is Bill's LDAP password
|
||||
uidNumber: 10003
|
||||
gidNumber: 10003
|
||||
homeDirectory: /home/bill.foobar
|
||||
loginShell: /bin/bash
|
||||
@@ -0,0 +1,20 @@
|
||||
dn: ou=groups,dc=stalwart,dc=test
|
||||
objectClass: organizationalUnit
|
||||
ou: groups
|
||||
|
||||
dn: cn=sales,ou=groups,dc=stalwart,dc=test
|
||||
objectClass: groupOfNames
|
||||
objectClass: extensibleObject
|
||||
cn: sales
|
||||
mail: [email protected]
|
||||
member: uid=john.doe,ou=users,dc=stalwart,dc=test
|
||||
member: uid=jane.smith,ou=users,dc=stalwart,dc=test
|
||||
|
||||
dn: cn=corporate,ou=groups,dc=stalwart,dc=test
|
||||
objectClass: groupOfNames
|
||||
objectClass: extensibleObject
|
||||
cn: corporate
|
||||
mail: [email protected]
|
||||
mailAlias: [email protected]
|
||||
member: uid=bill.foobar,ou=users,dc=stalwart,dc=test
|
||||
member: uid=jane.smith,ou=users,dc=stalwart,dc=test
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM ghcr.io/letsencrypt/pebble-challtestsrv:latest AS upstream
|
||||
|
||||
FROM alpine:latest
|
||||
COPY --from=upstream /app /app
|
||||
ENTRYPOINT ["/bin/sh", "-c", "HOSTIP=$(getent hosts host.docker.internal 2>/dev/null | awk '{print $1}' | head -n1); exec /app -defaultIPv6 \"\" -defaultIPv4 \"${HOSTIP:-127.0.0.1}\""]
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"pebble": {
|
||||
"listenAddress": "0.0.0.0:14000",
|
||||
"managementListenAddress": "0.0.0.0:15000",
|
||||
"certificate": "/test/certs/localhost/cert.pem",
|
||||
"privateKey": "/test/certs/localhost/key.pem",
|
||||
"httpPort": 8898,
|
||||
"tlsPort": 8899,
|
||||
"ocspResponderURL": "",
|
||||
"externalAccountBindingRequired": false,
|
||||
"domainBlocklist": [],
|
||||
"retryAfter": {
|
||||
"authz": 3,
|
||||
"order": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Wait for PowerDNS to be ready (started by default entrypoint)
|
||||
echo "Waiting for PowerDNS to start..."
|
||||
for i in $(seq 1 30); do
|
||||
if pdnsutil list-all-zones 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run zone initialization
|
||||
bash /etc/powerdns/init-zone.sh
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Wait for the SQLite database to be ready
|
||||
sleep 2
|
||||
|
||||
# Skip if already initialized
|
||||
if pdnsutil list-zone stalwart.test >/dev/null 2>&1; then
|
||||
echo "Zone stalwart.test already exists; skipping init."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create the zone with default SOA + NS
|
||||
pdnsutil create-zone stalwart.test ns1.stalwart.test
|
||||
pdnsutil set-kind stalwart.test native
|
||||
|
||||
# Replace the default SOA with our own
|
||||
pdnsutil replace-rrset stalwart.test '' SOA 'ns1.stalwart.test. admin.stalwart.test. 2024010101 3600 900 604800 86400'
|
||||
|
||||
# Add basic records
|
||||
pdnsutil add-record stalwart.test 'ns1' A '127.0.0.1'
|
||||
pdnsutil add-record stalwart.test '' A '127.0.0.1'
|
||||
pdnsutil add-record stalwart.test '' MX '10 mail.stalwart.test.'
|
||||
pdnsutil add-record stalwart.test 'mail' A '127.0.0.1'
|
||||
|
||||
# Add a sample TLSA record
|
||||
# Usage=3 (DANE-EE), Selector=1 (SubjectPublicKeyInfo), Matching=1 (SHA-256)
|
||||
pdnsutil add-record stalwart.test '_25._tcp.mail' TLSA '3 1 1 0000000000000000000000000000000000000000000000000000000000000000'
|
||||
|
||||
# Import static TSIG key for RFC2136 dynamic updates
|
||||
# Key: stalwart-update-key / HMAC-SHA256
|
||||
# Base64 secret: c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw
|
||||
pdnsutil import-tsig-key stalwart-update-key hmac-sha256 'c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw'
|
||||
pdnsutil activate-tsig-key stalwart.test stalwart-update-key primary
|
||||
pdnsutil set-meta stalwart.test TSIG-ALLOW-DNSUPDATE stalwart-update-key
|
||||
pdnsutil set-meta stalwart.test ALLOW-DNSUPDATE-FROM '0.0.0.0/0'
|
||||
|
||||
|
||||
echo "PowerDNS zone setup complete."
|
||||
echo "TSIG key name: stalwart-update-key"
|
||||
echo "TSIG algorithm: hmac-sha256"
|
||||
echo "TSIG secret (b64): c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw"
|
||||
@@ -0,0 +1,3 @@
|
||||
dnsupdate=yes
|
||||
allow-dnsupdate-from=0.0.0.0/0
|
||||
zone-cache-refresh-interval=0
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
"scim2-tester==0.2.8" \
|
||||
"scim2-client==0.7.5" \
|
||||
"scim2-models==0.6.12" \
|
||||
"httpx==0.28.1"
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drives the third party SCIM client libraries against a Stalwart service provider."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from scim2_client.engines.httpx import SyncSCIMClient
|
||||
from scim2_models import (
|
||||
Context,
|
||||
Error,
|
||||
ListResponse,
|
||||
PatchOp,
|
||||
PatchOperation,
|
||||
SearchRequest,
|
||||
ServiceProviderConfig,
|
||||
)
|
||||
from scim2_tester import check_server
|
||||
|
||||
|
||||
def allow_post_rfc7643_service_provider_config_attributes():
|
||||
"""RFC 9865 adds a top level `pagination` attribute to ServiceProviderConfig and
|
||||
draft-zollner-scim-interop-profile adds `interopProfileConformant`. scim2-models
|
||||
validates against RFC 7643 alone and forbids both."""
|
||||
ServiceProviderConfig.model_config["extra"] = "ignore"
|
||||
ServiceProviderConfig.model_rebuild(force=True)
|
||||
|
||||
|
||||
def build_client(url, token):
|
||||
return SyncSCIMClient(
|
||||
httpx.Client(
|
||||
base_url=url,
|
||||
headers={"Authorization": "Bearer " + token},
|
||||
verify=False,
|
||||
timeout=120.0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def conformance(client, _domain):
|
||||
results = check_server(client, raise_exceptions=False)
|
||||
return {
|
||||
"checks": [
|
||||
{
|
||||
"status": result.status.name,
|
||||
"title": result.title,
|
||||
"reason": result.reason,
|
||||
"tags": sorted(result.tags) if result.tags else [],
|
||||
"resource_type": result.resource_type,
|
||||
}
|
||||
for result in results
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def lifecycle(client, domain):
|
||||
steps = []
|
||||
|
||||
def record(name, detail=None):
|
||||
steps.append({"step": name, "ok": True, "detail": detail})
|
||||
|
||||
user_model = client.get_resource_model("User")
|
||||
group_model = client.get_resource_model("Group")
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
user_name = "lifecycle-{}@{}".format(tag, domain)
|
||||
renamed = "lifecycle-{}-renamed@{}".format(tag, domain)
|
||||
alias = "lifecycle-{}-alias@{}".format(tag, domain)
|
||||
display_name = "Lifecycle Person {}".format(tag)
|
||||
group_name = "Lifecycle Team {}".format(tag)
|
||||
user_id = None
|
||||
group_id = None
|
||||
|
||||
try:
|
||||
config = client.service_provider_config
|
||||
assert config.patch.supported is True
|
||||
assert config.etag.supported is True
|
||||
assert config.sort.supported is True
|
||||
assert config.change_password.supported is False
|
||||
assert config.bulk.max_operations == 1000, config.bulk
|
||||
assert config.filter.max_results == 200, config.filter
|
||||
record("discover", {
|
||||
"resource_types": sorted(
|
||||
resource_type.id for resource_type in client.resource_types
|
||||
),
|
||||
"resource_models": sorted(
|
||||
model.__name__ for model in client.resource_models
|
||||
),
|
||||
})
|
||||
|
||||
created = client.create(
|
||||
user_model(
|
||||
user_name=user_name,
|
||||
display_name=display_name,
|
||||
external_id="lifecycle-" + tag,
|
||||
emails=[{"value": alias}],
|
||||
),
|
||||
expected_status_codes=[201],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
user_id = created.id
|
||||
assert created.user_name == user_name, created.user_name
|
||||
assert created.display_name == display_name, created.display_name
|
||||
assert created.meta.location.endswith("/Users/" + user_id), created.meta.location
|
||||
assert created.meta.version, "the create response carried no version"
|
||||
assert "last_modified" not in type(created.meta).model_fields, "meta.lastModified was published"
|
||||
record("create_user", {"id": user_id, "version": created.meta.version})
|
||||
|
||||
fetched = client.query(
|
||||
user_model, user_id, expected_status_codes=[200], raise_scim_errors=True
|
||||
)
|
||||
assert fetched.id == user_id
|
||||
assert fetched.meta.version == created.meta.version
|
||||
assert [email.value for email in fetched.emails] == [user_name, alias], fetched.emails
|
||||
record("query_user_by_id")
|
||||
|
||||
response = client.client.get(
|
||||
"/Users", params={"filter": 'userName eq "{}"'.format(user_name)}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
listed = ListResponse[user_model].model_validate(
|
||||
response.json(), scim_ctx=Context.RESOURCE_QUERY_RESPONSE
|
||||
)
|
||||
assert listed.total_results == 1, listed.total_results
|
||||
assert listed.resources[0].id == user_id
|
||||
record("query_user_by_filter")
|
||||
|
||||
searched = client.search(
|
||||
search_request=SearchRequest(filter='userName eq "{}"'.format(user_name)),
|
||||
expected_status_codes=[200],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
assert searched.total_results == 1, searched.total_results
|
||||
record("search_endpoint")
|
||||
|
||||
patched = client.modify(
|
||||
user_model,
|
||||
user_id,
|
||||
PatchOp[user_model](
|
||||
operations=[
|
||||
PatchOperation(op="replace", path="displayName", value="Patched " + tag),
|
||||
PatchOperation(op="replace", path="active", value=False),
|
||||
]
|
||||
),
|
||||
expected_status_codes=[200],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
assert patched.display_name == "Patched " + tag, patched.display_name
|
||||
assert patched.active is False, patched.active
|
||||
assert patched.meta.version != created.meta.version, "the version did not change"
|
||||
record("patch_user")
|
||||
|
||||
replaced = client.replace(
|
||||
user_model(id=user_id, user_name=renamed),
|
||||
expected_status_codes=[200],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
assert replaced.user_name == renamed, replaced.user_name
|
||||
assert replaced.active is True, "a replace must reset active to its default"
|
||||
assert replaced.display_name is None, replaced.display_name
|
||||
record("replace_user")
|
||||
|
||||
group = client.create(
|
||||
group_model(display_name=group_name, members=[{"value": user_id}]),
|
||||
expected_status_codes=[201],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
group_id = group.id
|
||||
assert group.display_name == group_name
|
||||
assert [member.value for member in group.members] == [user_id], group.members
|
||||
record("create_group_with_member", {"id": group_id})
|
||||
|
||||
member_view = client.query(
|
||||
user_model, user_id, expected_status_codes=[200], raise_scim_errors=True
|
||||
)
|
||||
assert [entry.value for entry in member_view.groups] == [group_id], member_view.groups
|
||||
record("membership_visible_on_user")
|
||||
|
||||
emptied = client.modify(
|
||||
group_model,
|
||||
group_id,
|
||||
PatchOp[group_model](
|
||||
operations=[PatchOperation(op="remove", path="members")]
|
||||
),
|
||||
expected_status_codes=[200],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
assert not emptied.members, emptied.members
|
||||
record("patch_group_members")
|
||||
|
||||
client.delete(group_model, group_id, expected_status_codes=[204], raise_scim_errors=True)
|
||||
group_id = None
|
||||
record("delete_group")
|
||||
|
||||
client.delete(user_model, user_id, expected_status_codes=[204], raise_scim_errors=True)
|
||||
gone = client.query(
|
||||
user_model,
|
||||
user_id,
|
||||
expected_status_codes=[404],
|
||||
raise_scim_errors=False,
|
||||
)
|
||||
assert isinstance(gone, Error), gone
|
||||
assert gone.status == 404, gone
|
||||
user_id = None
|
||||
record("delete_user")
|
||||
except Exception as err:
|
||||
steps.append(
|
||||
{
|
||||
"step": "aborted",
|
||||
"ok": False,
|
||||
"detail": "".join(
|
||||
traceback.format_exception(type(err), err, err.__traceback__)
|
||||
),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
for model, resource_id in ((group_model, group_id), (user_model, user_id)):
|
||||
if resource_id:
|
||||
try:
|
||||
client.delete(model, resource_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"steps": steps}
|
||||
|
||||
|
||||
def clients(client, domain):
|
||||
"""Replays the payload shapes that Okta and the Keycloak community extensions send."""
|
||||
steps = []
|
||||
user_model = client.get_resource_model("User")
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
created = []
|
||||
|
||||
def check(name, fn):
|
||||
try:
|
||||
fn()
|
||||
steps.append({"step": name, "ok": True, "detail": None})
|
||||
except Exception as err:
|
||||
steps.append(
|
||||
{
|
||||
"step": name,
|
||||
"ok": False,
|
||||
"detail": "".join(
|
||||
traceback.format_exception(type(err), err, err.__traceback__)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def post(name, payload):
|
||||
def run():
|
||||
response = client.client.post("/Users", json=payload)
|
||||
assert response.status_code == 201, "{} {}".format(
|
||||
response.status_code, response.text
|
||||
)
|
||||
body = response.json()
|
||||
created.append(body["id"])
|
||||
assert body["userName"] == payload["userName"], body
|
||||
assert "password" not in response.text, body
|
||||
if payload.get("displayName"):
|
||||
assert body["displayName"] == payload["displayName"], body
|
||||
elif payload.get("name", {}).get("givenName"):
|
||||
expected = " ".join(
|
||||
part
|
||||
for part in (
|
||||
payload["name"].get("givenName"),
|
||||
payload["name"].get("familyName"),
|
||||
)
|
||||
if part
|
||||
)
|
||||
assert body["displayName"] == expected, body
|
||||
user_model.model_validate(body, scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
|
||||
|
||||
check(name, run)
|
||||
|
||||
okta_name = "okta-{}@{}".format(tag, domain)
|
||||
post(
|
||||
"okta_create_user",
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": okta_name,
|
||||
"name": {"givenName": "Barbara", "familyName": "Jensen"},
|
||||
"emails": [{"primary": True, "value": okta_name, "type": "work"}],
|
||||
"displayName": "Barbara Jensen",
|
||||
"locale": "en-US",
|
||||
"externalId": "00u" + tag,
|
||||
"groups": [],
|
||||
"password": "correct horse battery staple",
|
||||
"active": True,
|
||||
"title": "Vice President",
|
||||
"userType": "Employee",
|
||||
"phoneNumbers": [{"value": "555-0100", "type": "work"}],
|
||||
},
|
||||
)
|
||||
|
||||
keycloak_name = "keycloak-{}@{}".format(tag, domain)
|
||||
post(
|
||||
"keycloak_create_user",
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": keycloak_name,
|
||||
"name": {"givenName": "Wile", "familyName": "Coyote"},
|
||||
"emails": [{"value": keycloak_name, "primary": True}],
|
||||
"active": True,
|
||||
"externalId": "kc-" + tag,
|
||||
},
|
||||
)
|
||||
|
||||
entra_name = "entra-{}@{}".format(tag, domain)
|
||||
post(
|
||||
"entra_create_user",
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||
],
|
||||
"userName": entra_name,
|
||||
"name": {"givenName": "Road", "familyName": "Runner"},
|
||||
"displayName": "Road Runner",
|
||||
"emails": [{"value": entra_name, "type": "work", "primary": True}],
|
||||
"active": True,
|
||||
"externalId": "entra-" + tag,
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
|
||||
"department": "Sales",
|
||||
"employeeNumber": "42",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if created:
|
||||
okta_id = created[0]
|
||||
|
||||
def okta_put():
|
||||
response = client.client.put(
|
||||
"/Users/" + okta_id,
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": okta_id,
|
||||
"userName": okta_name,
|
||||
"name": {"givenName": "Babs", "familyName": "Jensen"},
|
||||
"emails": [{"primary": True, "value": okta_name, "type": "work"}],
|
||||
"displayName": "Babs Jensen",
|
||||
"active": False,
|
||||
"password": "correct horse battery staple",
|
||||
"title": "President",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["displayName"] == "Babs Jensen", body
|
||||
assert body["active"] is False, body
|
||||
|
||||
check("okta_replace_user", okta_put)
|
||||
|
||||
def keycloak_patch():
|
||||
response = client.client.patch(
|
||||
"/Users/" + created[1],
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [{"op": "replace", "value": {"active": "false"}}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["active"] is False, response.text
|
||||
|
||||
check("keycloak_deactivate_user", keycloak_patch)
|
||||
|
||||
def entra_patch():
|
||||
response = client.client.patch(
|
||||
"/Users/" + created[2],
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{"op": "replace", "path": "name.givenName", "value": "Wile"},
|
||||
{"op": "replace", "path": "displayName", "value": "Wile Runner"},
|
||||
{
|
||||
"op": "add",
|
||||
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
|
||||
"value": "Marketing",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["displayName"] == "Wile Runner", response.text
|
||||
|
||||
check("entra_patch_user", entra_patch)
|
||||
|
||||
def okta_lookup():
|
||||
response = client.client.get(
|
||||
"/Users", params={"filter": 'userName eq "{}"'.format(okta_name)}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
listed = ListResponse[user_model].model_validate(
|
||||
response.json(), scim_ctx=Context.RESOURCE_QUERY_RESPONSE
|
||||
)
|
||||
assert listed.total_results == 1, listed.total_results
|
||||
|
||||
check("okta_lookup_by_username", okta_lookup)
|
||||
|
||||
def rejects_typos():
|
||||
response = client.client.post(
|
||||
"/Users",
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "typo-{}@{}".format(tag, domain),
|
||||
"dispalyName": "Typo",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400, response.text
|
||||
assert response.json()["scimType"] == "invalidSyntax", response.text
|
||||
|
||||
check("misspelled_attributes_are_still_rejected", rejects_typos)
|
||||
|
||||
for resource_id in created:
|
||||
try:
|
||||
client.client.delete("/Users/" + resource_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"steps": steps}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", required=True)
|
||||
parser.add_argument("--token", required=True)
|
||||
parser.add_argument("--domain", required=True)
|
||||
parser.add_argument(
|
||||
"--mode", required=True, choices=["conformance", "lifecycle", "clients"]
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
allow_post_rfc7643_service_provider_config_attributes()
|
||||
client = build_client(args.url, args.token)
|
||||
try:
|
||||
client.discover()
|
||||
except Exception as err:
|
||||
json.dump(
|
||||
{"error": "discovery failed: " + "".join(
|
||||
traceback.format_exception(type(err), err, err.__traceback__)
|
||||
)},
|
||||
sys.stdout,
|
||||
)
|
||||
sys.stdout.write("\n")
|
||||
return
|
||||
|
||||
runner = {"conformance": conformance, "lifecycle": lifecycle, "clients": clients}[
|
||||
args.mode
|
||||
]
|
||||
json.dump(runner(client, args.domain), sys.stdout)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
CERT_DIR=/certs
|
||||
|
||||
if [ ! -f "$CERT_DIR/cert.pem" ]; then
|
||||
echo "Generating self-signed certificate..."
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$CERT_DIR/key.pem" \
|
||||
-out "$CERT_DIR/cert.pem" \
|
||||
-days 365 \
|
||||
-subj "/CN=localhost/O=Stalwart Test/C=US" \
|
||||
-addext "subjectAltName=DNS:localhost,DNS:keycloak,DNS:openldap,DNS:pebble,DNS:*.stalwart.test,IP:127.0.0.1"
|
||||
|
||||
# Create combined PEM for services that need it
|
||||
cat "$CERT_DIR/cert.pem" "$CERT_DIR/key.pem" > "$CERT_DIR/combined.pem"
|
||||
|
||||
# Create PKCS12 for Keycloak
|
||||
openssl pkcs12 -export -in "$CERT_DIR/cert.pem" -inkey "$CERT_DIR/key.pem" \
|
||||
-out "$CERT_DIR/keystore.p12" -name localhost -password pass:changeit
|
||||
|
||||
chmod 644 "$CERT_DIR"/*
|
||||
echo "Certificates generated."
|
||||
else
|
||||
echo "Certificates already exist."
|
||||
fi
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if fdbcli --exec 'status minimal' --timeout 5 2>&1 | grep -q "The database is available"; then
|
||||
echo "FoundationDB already configured."
|
||||
exit 0
|
||||
fi
|
||||
if fdbcli --exec 'configure new single memory' --timeout 5 2>&1 | grep -q "Database created"; then
|
||||
echo "FoundationDB configured."
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for FoundationDB to be ready (attempt $i)..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "ERROR: Failed to configure FoundationDB after retries" >&2
|
||||
exit 1
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Wait for MinIO to be ready
|
||||
echo "Waiting for MinIO..."
|
||||
until mc alias set local http://minio:9000 minioadmin minioadmin 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Create the stalwart bucket
|
||||
mc mb local/stalwart --ignore-existing
|
||||
echo "MinIO bucket 'stalwart' created."
|
||||
Reference in New Issue
Block a user