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
| Aggregate | Direction | Conflict policy | Reason |
|---|---|---|---|
User (snapshot only — no PII edits) | server → client | server_authoritative | Identity must never be locally mutable. |
Device (own device only) | server ↔ client (limited) | server_authoritative | last_seen_at, display_name may be client-pushed; everything else server-only. |
OfflineBinding (cert + revocation status) | server → client | server_authoritative | Revocation always wins. |
MFAFactor (kind + label only — no secrets) | server → client | server_authoritative | Used to render UI; secrets stay server-side. |
Session (own active sessions list) | server → client (read), server-side write | server_authoritative | Lets desktop show "active sessions" panel offline. |
JWKSSnapshot (signing keys + tenant CA) | server → client | server_authoritative | For offline JWT + offline cert verification. |
AuditEvent (own actor) | client → server | append_only | Desktop 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
| Aggregate | Policy | Tiebreaker |
|---|---|---|
User | server_authoritative | Server version always wins. |
Device.heartbeat | lww (last-write-wins) | server now() if drift > 60 s. |
Device.displayName | lww | server updated_at. |
OfflineBinding | server_authoritative | Revocation wins instantly. |
JWKSSnapshot | server_authoritative | Whole-snapshot replace. |
AuditEvent | append_only | Server stamps occurred_at if drift > 30 s; client client_occurred_at retained. |
Session.summary | server_authoritative | Pull-only; revocation propagates within next pull. |
See platform conflict catalog: 02 §12.4.
5. Offline JWT Validation Contract
The desktop MUST:
- Hold a current
iam.jwkssnapshot (≤ 24 h stale). - Verify EdDSA signature using
kid → publicKeymap. - 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)
| Store | Schema | Encryption |
|---|---|---|
SQLite (iam_snapshot.db) | users_snapshot, devices, offline_bindings, mfa_factors_summary, sessions_summary, jwks | SQLCipher (AES-256), key from OS keychain |
OS keychain (keytar) | device_private_key (Ed25519), current_refresh_token | OS-native (Keychain / DPAPI / libsecret) |
| Outbox (audit only) | iam_audit_buffer | SQLCipher; 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
| Failure | Behaviour |
|---|---|
| Pull while binding cert revoked | Empty payload + MELMASTOON.IAM.OFFLINE_BINDING_REVOKED; client must re-bind. |
| Pull while account locked | MELMASTOON.IAM.ACCOUNT_LOCKED and forced logout. |
| Push audit older than 7 d | Server stamps occurred_at = now() and adds metadata late_push=true. |
| JWKS rotation mid-offline | Old kid retained for 7 d (overlap window) so offline clients still verify. |
9. Cross-References
- Sync API (platform): 05 §10
- Sync architecture: 02 §12
- Desktop offline ADR: ADR-0003
- Cert + signing crypto: SECURITY_MODEL §4