Task — engineering-spec@1

"EMAIL Stalwart authbridge plugin — JMAP/IMAP/SMTP auth delegates to AUTH JWT validation + per-tenant mailbox scoping"

draftTASK-EMAIL-002
module email · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-EMAIL-001, TASK-AUTH-004 · blocks none

§1 — Description (BCP-14 normative)

The EMAIL service MUST ship Stalwart authbridge plugin at services/email/src/authbridge/ validating TASK-AUTH-004 JWTs against per-tenant mailbox scope, with 60s Redis cache, TASK-PORTAL-004 SCIM revoke cascade, 6-outcome enum, and 4 memory audit kinds.

  1. MUST define closed auth_outcome enum: ('success','jwt_invalid','jwt_expired','jwt_wrong_audience','subject_revoked','mailbox_unauthorized') per DEC-1463. Cardinality 6.
  1. MUST expose POST /v1/email/auth (Stalwart HTTP auth backend) body { username, password, protocol }. Handler:
  1. MUST resolve mailbox path per DEC-1462: subject_id@tenant_slug.cyberos.world. Per-Engagement aliases possible via TASK-PORTAL-002 CNAME mapping.
  1. MUST cache validated JWTs 60s per DEC-1464. Redis key = email_auth:{jti}; value = { outcome, mailbox_path, permissions, exp }.
  1. MUST subscribe to TASK-PORTAL-004 SCIM revoke NATS events per DEC-1465. Consumer invalidates Redis cache for subject + emits email.auth_failed reason='subject_revoked' for next attempt.
  1. MUST define email_auth_log at migration 0001: (id BIGSERIAL, tenant_id UUID, subject_id UUID, protocol TEXT, outcome auth_outcome, source_ip_hash16 TEXT, ts TIMESTAMPTZ DEFAULT now()). Append-only.
  1. MUST emit 4 memory audit kinds per DEC-1466. PII-scrub source_ip via TASK-MEMORY-111.
  1. MUST thread trace_id end-to-end.
  1. MUST NOT cache JWT > 60s per DEC-1464.
  1. MUST NOT allow cross-tenant mailbox per DEC-1462.

§2 — Why this design (rationale)

Why JWT-as-password (DEC-1461)? IMAP/SMTP clients expect username/password; JWT in password field = client-transparent SSO without new protocols. Industry pattern (Google App Passwords replaced by OAuth, but base auth still username/password mechanism).

Why 60s cache (DEC-1464)? IMAP IDLE keeps connection open + re-auths frequently. Per-request JWT verify = ~5ms × 100 req/s = 500ms/s CPU; 60s cache = ~99% hit rate.

Why SCIM cascade (DEC-1465)? Without it, JWT exp window (8h IdP-auth) is the only revocation timeline. Cascade brings it to <60s.


§3 — API contract

-- 0001_email_auth_log.sql
CREATE TYPE auth_outcome AS ENUM ('success','jwt_invalid','jwt_expired','jwt_wrong_audience','subject_revoked','mailbox_unauthorized');

CREATE TABLE email_auth_log (
  id BIGSERIAL PRIMARY KEY,
  tenant_id UUID,
  subject_id UUID,
  protocol TEXT NOT NULL CHECK (protocol IN ('imap','smtp','jmap','managesieve')),
  outcome auth_outcome NOT NULL,
  source_ip_hash16 TEXT,
  ts TIMESTAMPTZ NOT NULL DEFAULT now(),
  trace_id CHAR(32)
);
CREATE INDEX idx_auth_log_subject ON email_auth_log(subject_id, ts DESC);
ALTER TABLE email_auth_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY email_auth_log_rls ON email_auth_log
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON email_auth_log FROM cyberos_app;

Stalwart backend HTTP contract: POST /v1/email/auth returns { outcome, mailbox_path, permissions: [...] }.


§4 — Acceptance criteria

  1. auth_outcome cardinality 6.
  2. Valid JWT → success + mailbox_path returned.
  3. Expired JWT → jwt_expired + no cache.
  4. Wrong audience → jwt_wrong_audience.
  5. Username mismatch → mailbox_unauthorized.
  6. 60s cache hit — second request within 60s returns cached.
  7. Cache TTL respected — at T+61s, re-validates.
  8. SCIM revoke invalidates cache — NATS event → next request 'subject_revoked'.
  9. 4 memory audit kinds emitted.
  10. Per-tenant mailbox path correctsubj@tenant.cyberos.world format.
  11. Cross-tenant attempt denied — subject from tenant A trying tenant B mailbox → mailbox_unauthorized.
  12. Source IP PII-scrubbed.
  13. Trace_id end-to-end.
  14. Stalwart integration smoke — real IMAP login via Stalwart succeeds.
  15. Redis unavailable fallback — falls through to per-request JWT verify.
  16. JWKS rotation handled — TASK-AUTH-004 key rotation propagates.
  17. Protocol enum validated — unknown protocol → 400.
  18. Non-JWT password rejected — random string → jwt_invalid.
  19. RLS denies cross-tenant log read.
  20. Audit on every outcome.

§5 — Verification

#[tokio::test]
async fn valid_jwt_returns_success() {
    let ctx = TestContext::new().await;
    let jwt = ctx.mint_jwt(ctx.subject_id, ctx.tenant_id).await;
    let r = ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
    assert_eq!(r.status(), 200);
    let body: serde_json::Value = r.json().await.unwrap();
    assert_eq!(body["outcome"], "success");
    assert_eq!(body["mailbox_path"], "alice@acme.cyberos.world");
}

#[tokio::test]
async fn cache_hit_within_60s() {
    let ctx = TestContext::new().await;
    let jwt = ctx.mint_jwt(ctx.subject_id, ctx.tenant_id).await;
    ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
    let metrics_before = ctx.jwt_verify_count();
    ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
    let metrics_after = ctx.jwt_verify_count();
    assert_eq!(metrics_after, metrics_before);  // cached, no re-verify
}

#[tokio::test]
async fn scim_revoke_invalidates() {
    let ctx = TestContext::new().await;
    let jwt = ctx.mint_jwt(ctx.subject_id, ctx.tenant_id).await;
    ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
    ctx.publish_scim_revoke_event(ctx.subject_id).await;
    tokio::time::sleep(Duration::from_millis(200)).await;
    let r = ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
    let body: serde_json::Value = r.json().await.unwrap();
    assert_eq!(body["outcome"], "subject_revoked");
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-EMAIL-001, TASK-AUTH-004. Cross-module: TASK-PORTAL-004 (SCIM cascade), TASK-AI-003, TASK-MEMORY-111.

§10 — Failure modes

FailureDetectionOutcomeRecovery
JWKS unreachabletimeout 5sSev-2; serve from cached JWKSAUTH recovery
Redis unavailableerrorFallback per-request verify; sev-3Redis recovery
JWT clock skewexp check tolerance ±60sInherentNTP
Stalwart auth timeout10s defaultSev-2Stalwart investigation
Cross-tenant attemptusername checkmailbox_unauthorizedInherent
SCIM cascade delayNATS lagSev-3; cache TTL covers worst caseNATS recovery
Same JWT replay > 60s past expexp checkjwt_expiredInherent
Cache poisoningRedis ACLInherentRedis isolation
Protocol enum unknowncheck400Stalwart config fix
Per-request rate spikerate limit429Inherent

§11 — Implementation notes

§11.1 Stalwart auth.backend.type = http configured to point at /v1/email/auth. §11.2 JWT-as-password tested with real IMAP clients (Apple Mail, Thunderbird). §11.3 Per-tenant mailbox isolation enforced by Stalwart given correct mailbox_path return. §11.4 Redis cache pruned at TTL automatically. §11.5 Source IP hashed before persist; never raw IP in audit chain.


End of TASK-EMAIL-002 spec.