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​