Skip to content

RP Migration Guide — Adopting canonical_sub

This guide covers the procedure for an RP that is already integrated with logi to switch safely to canonical_sub-based user resolution while keeping its existing sub foreign keys. The RP-side work must be complete before logi turns on the first merge trigger in production.

The big picture

Your existing RP DB references a logi user like this:

users (RP own table)
  logi_sub  string  unique   ← the logi user's ID

After migration:

users (kept as-is)
  logi_sub  string  unique

logi_identity_links (new)
  primary_user_id  string  ← canonical_sub
  linked_user_id   string  unique  ← the absorbed sub
  merged_via       string
  occurred_at      datetime

Every user lookup in your RP passes through a canonical resolver:

ruby
def find_canonical_user(sub)
  canonical = LogiIdentityLink.find_by(linked_user_id: sub)&.primary_user_id || sub
  User.find_by(logi_sub: canonical)
end

The 5-phase deploy order

This is a joint deploy coordinated with logi. We recommend a 24-hour soak after each phase before the next.

Phase 1 — Schema + inactive code

  • Add the logi_identity_links table + index
  • Add the canonical resolver code (unused; feature flag enforce_canonical_resolution = false)
  • The existing user-lookup path keeps using logi_sub directly

Phase 2 — Enable the webhook receiver

  • Enable the /webhooks/logi endpoint → on receiving user.merged, INSERT a logi_identity_links row
  • Implement HMAC verification + event_id dedupe
  • At this point logi has not yet fired any merge, so no rows arrive — the receiver's code path is effectively a dry run

Phase 3 — Enable the polling reconciler

  • A per-minute GET /api/v1/events?since=<cursor> polling job
  • Store the cursor in your own DB (advance the cursor + apply events within one transaction)

Phase 4 — Enable the login-time resolver

  • A BearerAuthentication (or equivalent) concern looks at canonical_sub on every token verification, compares it against logi_identity_links, and inserts a row immediately if there's a difference
  • This is the last safety net for anything webhook/polling missed

Phase 5 — Flip enforce_canonical_resolution = true

  • Change every user lookup so it goes through the canonical resolver
  • Use grep to confirm no direct User.find_by(logi_sub:) calls remain in the codebase (we recommend bin/rails runner + a custom audit)

logi turns on the merge trigger in production only after this flip.

Code patterns for Rails

Concern example

ruby
# app/controllers/concerns/logi_canonical_resolution.rb
module LogiCanonicalResolution
  extend ActiveSupport::Concern

  private

  def resolve_logi_user(sub:, canonical_sub:, linked_subs: [])
    # 1) login-time fallback: backfill here when webhook/polling missed an event
    if canonical_sub != sub && !LogiIdentityLink.exists?(linked_user_id: sub)
      LogiIdentityLink.create!(
        primary_user_id: canonical_sub,
        linked_user_id:  sub,
        merged_via:      "login_time_fallback",
        occurred_at:     Time.current
      )
    end

    # 2) always look up by canonical
    User.find_by(logi_sub: canonical_sub)
  end
end

A note on Pundit policies

If a Pundit policy does a direct comparison such as record.user_id == current_user.id, permissions can drop in the case where, after a merge, the same person has two rows with different logi_subs. The recommended pattern:

ruby
class ApplicationPolicy
  def same_canonical?(record_user)
    LogiCanonical.same_owner?(current_user, record_user)
  end
end

LogiCanonical.same_owner? resolves each user's logi_sub to its canonical and then compares them. For detailed patterns, see the Rails integration guide.

Merge Reconciler

When user.merged arrives via webhook or polling:

ruby
class MergeReconciler
  def apply(event)
    survivor = event[:data][:survivor_canonical_sub]
    merged   = event[:data][:merged_sub]

    LogiIdentityLink.create_or_find_by!(linked_user_id: merged) do |link|
      link.primary_user_id = survivor
      link.merged_via      = event[:data][:merged_via]
      link.occurred_at     = Time.parse(event[:data][:triggered_at])
    end

    # The logical merge of domain data is up to the RP's policy.
    # e.g. force-terminate merged's active sessions, combine both users' order history, etc.
  end
end

create_or_find_by! guarantees idempotency, so it's safe even if the same webhook arrives twice.

What does not change

  • Existing logi_sub foreign keys stay as-is. A row's logi_sub is not changed by the migration.
  • The logi_sub of a newly signed-up user also stays the same. sub is stable under OIDC.
  • Your RP's external API responses — if you exposed a user identifier externally, that value stays the same too.

What changes

  • When a user who has been merged receives a new token, canonical_sub differs from sub.
  • linked_subs may be populated.
  • Every RP-side user lookup has to take one extra hop through the canonical resolver.

Verification checklist

Before the flip:

  • [ ] The LogiIdentityLink table + a UNIQUE(linked_user_id) index
  • [ ] A webhook receiver with HMAC + event_id dedupe implemented
  • [ ] A cursor-based polling reconciler (per-minute recommended)
  • [ ] A login-time canonical resolver
  • [ ] All direct User.find_by(logi_sub:) calls replaced with calls that go through the canonical resolver
  • [ ] Pundit policies / scopes use canonical comparison
  • [ ] Ready to flip the feature flag enforce_canonical_resolution = true

After you notify the logi operators that the flip is complete, logi enables the T1 trigger in production.

Identity가 제품의 신뢰를 만듭니다.