Skip to main content

iam-service — Sync Contract

Catalog summary: docs/03-microservices/iam-service.md · 02 §12 Sync & Offline · ADR-0003 Electron Offline-First · DOMAIN_MODEL · SECURITY_MODEL §3.6

iam-service is mostly read-only in the offline-sync world. The Electron desktop never holds writable copies of credentials, sessions, or MFA secrets — those are server-authoritative. What it does need is a principal snapshot to validate JWTs offline, a device-binding state, and the public material to verify offline-token signatures.

1. Sync Surface — Aggregates Exposed

AggregateDirectionConflict policyReason
User (snapshot only — no PII edits)server → clientserver_authoritativeIdentity must never be locally mutable.
Device (own device only)server ↔ client (limited)server_authoritativelast_seen_at, display_name may be client-pushed; everything else server-only.
OfflineBinding (cert + revocation status)server → clientserver_authoritativeRevocation always wins.
MFAFactor (kind + label only — no secrets)server → clientserver_authoritativeUsed to render UI; secrets stay server-side.
Session (own active sessions list)server → client (read), server-side writeserver_authoritativeLets desktop show "active sessions" panel offline.
JWKSSnapshot (signing keys + tenant CA)server → clientserver_authoritativeFor offline JWT + offline cert verification.
AuditEvent (own actor)client → serverappend_onlyDesktop can buffer audit while offline; server appends in order.

Credential, raw MFAFactor.totpSecretEnc, raw refresh tokens, password hashes, and other tenants' data are never exposed to the sync surface.

2. Pull Contract

The Electron desktop calls the platform sync endpoint described in 05 §10:

POST /sync/v1/pull
Authorization: Bearer <accessJwt>
X-Tenant-Id: ten_01HZ8X...
X-Device-Id: dev_01HZ8X...
Content-Type: application/json

{
"since": {
"iam.user.snapshot": "01HZ8X...K",
"iam.device": "01HZ8X...M",
"iam.offline_binding": "01HZ8X...N",
"iam.mfa_factor.summary": "01HZ8X...P",
"iam.session.summary": "01HZ8X...Q",
"iam.jwks": "2026-04-22T18:00:00Z"
},
"limits": { "perAggregate": 500 }
}

Response:

{
"data": {
"iam.user.snapshot": [{
"id": "usr_01HZ8X...",
"tenantId": "ten_01HZ8X...",
"userType": "staff",
"status": "active",
"emailVerified": true,
"version": 17,
"syncCursor": "01HZ8XQ..."
}],
"iam.device": [{
"id": "dev_01HZ8X...",
"displayName": "Front Desk Mac",
"trusted": true,
"lastSeenAt": "2026-04-22T17:55:00Z",
"syncCursor": "01HZ8XQ..."
}],
"iam.offline_binding": [{
"deviceId": "dev_01HZ8X...",
"serial": "01HZ8X...",
"notAfter": "2026-04-29T18:00:00Z",
"revoked": false,
"syncCursor": "01HZ8XQ..."
}],
"iam.mfa_factor.summary": [{
"id": "mfa_01HZ8X...",
"kind": "totp",
"label": "Phone",
"enrolledAt": "2026-03-01T10:00:00Z"
}],
"iam.session.summary": [{
"id": "ses_01HZ8X...",
"deviceId": "dev_01HZ8X...",
"issuedAt": "2026-04-22T08:00:00Z",
"expiresAt": "2026-04-22T08:15:00Z",
"amr": ["pwd","totp"]
}],
"iam.jwks": [{
"kid": "iam-2026-04",
"kty": "OKP",
"crv": "Ed25519",
"x": "…base64url…",
"use": "sig",
"alg": "EdDSA",
"rotatedAt": "2026-04-01T00:00:00Z"
}]
},
"next": { "iam.user.snapshot": "01HZ8XQ..." },
"hasMore": false
}

Snapshots, not deltas, are sent for User and OfflineBinding (small surface area). Sync cursors are durable Firestore doc IDs (sync/iam/<aggregate>/<deviceId>), monotonic ULIDs.

3. Push Contract (limited)

Only two payloads are accepted from the client:

POST /sync/v1/push
{
"operations": [
{ "aggregate": "iam.device.heartbeat",
"deviceId": "dev_01HZ8X...",
"lastSeenAt": "2026-04-22T18:01:23Z" },
{ "aggregate": "iam.device.label",
"deviceId": "dev_01HZ8X...",
"displayName": "Front Desk — Bar Hall" },
{ "aggregate": "iam.audit.buffered",
"events": [
{ "occurredAt": "2026-04-22T17:55:00Z",
"action": "ui.locked_session_offline",
"actorUserId": "usr_01HZ8X...",
"metadata": { "reason": "user_action" } }
]
}
]
}

Every other write (login, MFA, key issuance, lockout) requires the desktop to be online and uses normal REST endpoints.

4. Conflict Resolution

AggregatePolicyTiebreaker
Userserver_authoritativeServer version always wins.
Device.heartbeatlww (last-write-wins)server now() if drift > 60 s.
Device.displayNamelwwserver updated_at.
OfflineBindingserver_authoritativeRevocation wins instantly.
JWKSSnapshotserver_authoritativeWhole-snapshot replace.
AuditEventappend_onlyServer stamps occurred_at if drift > 30 s; client client_occurred_at retained.
Session.summaryserver_authoritativePull-only; revocation propagates within next pull.

See platform conflict catalog: 02 §12.4.

5. Offline JWT Validation Contract

The desktop MUST:

  1. Hold a current iam.jwks snapshot (≤ 24 h stale).
  2. Verify EdDSA signature using kid → publicKey map.
  3. Reject if exp < now. Refresh path while offline:
    • Sign refresh request with device key (Ed25519).
    • Include offline binding cert in request envelope.
    • Cache rotated tokens locally (SQLCipher only).
    • Reconcile with server on next online sync; rejected refreshes invalidate local session.

Hard limit: offline operation ≤ 7 days (or tenant policy max_offline_grace_h, whichever lower). Beyond that, certificate not_after strictly bars all token issuance.

6. Local Storage (Electron desktop)

StoreSchemaEncryption
SQLite (iam_snapshot.db)users_snapshot, devices, offline_bindings, mfa_factors_summary, sessions_summary, jwksSQLCipher (AES-256), key from OS keychain
OS keychain (keytar)device_private_key (Ed25519), current_refresh_tokenOS-native (Keychain / DPAPI / libsecret)
Outbox (audit only)iam_audit_bufferSQLCipher; capped at 10 MB; oldest dropped if full

The desktop SQLite schema is documented in 06 Data Models §7.

7. Sync Cursors (Firestore)

sync-service keeps cursors per (deviceId, aggregate):

collection: sync/iam
doc: dev_01HZ8X.../user.snapshot
fields:
cursor: "01HZ8XQ..."
lastPullAt: Timestamp
version: 17

Pull endpoint reads/writes via sync-service; iam-service doesn't touch Firestore directly.

8. Failure Modes

FailureBehaviour
Pull while binding cert revokedEmpty payload + MELMASTOON.IAM.OFFLINE_BINDING_REVOKED; client must re-bind.
Pull while account lockedMELMASTOON.IAM.ACCOUNT_LOCKED and forced logout.
Push audit older than 7 dServer stamps occurred_at = now() and adds metadata late_push=true.
JWKS rotation mid-offlineOld kid retained for 7 d (overlap window) so offline clients still verify.

9. Cross-References