Kulcare Docs
General

Authentication & Authorization System

Last Updated: 2025-01-20 Status: Implemented Schema Version: New (identity schema with multi-tenancy)

For a developer-focused guide covering code-level detail, frontend implementation, and the dual legacy/new system, see Developer Guide: Authentication System.

Overview

This document describes the complete authentication and authorization system for the Kulcare multi-tenant healthcare platform. The system uses JWT-based stateless authentication with session-backed tenant context, supporting multi-tenant users who can belong to multiple organizations.


Architecture Summary

Key Design Decisions

AspectDecisionRationale
JWT FormatTenant-agnostic for login, tenant-specific for profile switchAllows profile viewing without commitment, explicit tenant selection for actions
Context StorageSession record (tenant_id, facility_id)Enables RLS without re-issuing tokens, supports profile switching
Token IssuanceCentralized via TokenIssuanceServiceSingle source of truth, consistent behavior across flows
Session ManagementSession reuse with context updatesClean audit trail, no duplicate sessions
RLS EnforcementPostgreSQL session variablesDatabase-level isolation, performant queries

Two-Mode JWT Architecture

The system implements two distinct JWT modes based on authentication context:

Mode 1: Login (Initial Authentication)

JWT Payload:

{
  "user_id": "uuid-v7",
  "exp": 1737392000
}

Purpose: Tenant-agnostic token allows users to:

  • View all available tenants/organizations
  • Browse profile options before committing
  • Switch between profiles without re-authentication

Session Context:

# identity.sessions table
{
  user_id: "uuid-v7",
  tenant_id: "primary-tenant-uuid",  # Set from primary membership
  facility_id: null,
  auth_token_hash: "SHA256(access_token)",
  refresh_token_id: "uuid"
}

Mode 2: Profile Switch (Committed Context)

JWT Payload:

{
  "user_id": "uuid-v7",
  "tenant_id": "selected-tenant-uuid",
  "facility_id": "selected-facility-uuid",
  "role": "admin",
  "exp": 1737392000
}

Purpose: Tenant-specific token commits user to:

  • Specific tenant context for all operations
  • Facility-level access control
  • Role-based permissions within tenant
  • Direct RLS application without session lookup

Session Context:

# identity.sessions table
{
  user_id: "uuid-v7",
  tenant_id: "selected-tenant-uuid",
  facility_id: "selected-facility-uuid",
  auth_token_hash: "SHA256(new_access_token)",
  refresh_token_id: "new-refresh-token-uuid"
}

Core Components

1. TokenIssuanceService

Location: backend/domains/identity/services/tokens/token_issuance_service.rb

Purpose: Centralized service for all token generation and session management.

Key Features:

  • Single source of truth for token issuance
  • Supports session creation and update
  • Handles profile switch with context updates
  • Validates membership and tenant status

Interface:

result = Identity::Tokens::TokenIssuanceService.call(
  user: user,
  membership: membership,           # Defines tenant context
  device: device,
  existing_session: nil,            # Optional: reuse session
  revoke_previous_tokens: false,    # For profile switch
  profile_switch_id: nil,
  metadata: {}
)

# Returns
{
  access_token: "eyJ...",
  refresh_token: "eyJ...",
  session: session_object,
  expires_in: 86400,
  token_type: "Bearer",
  session_id: "uuid"
}

Token Generation:

def issue_tokens
  # Access token: ONLY user_id (tenant-agnostic)
  @access_token = Identity::IdentityTokenIssuer.issue_access_token(
    user: @user
    # No tenant/membership passed - JWT is tenant-agnostic
  )

  # Refresh token: ONLY user_id
  @refresh_token = generate_refresh_token
end

2. TenantContext Concern

Location: backend/domains/shared/concerns/tenant_context.rb

Purpose: Extracts tenant context from Session and sets RLS variables at the request boundary.

Flow:

1. Extract user_id from JWT
2. Find active Session by auth token hash
3. Get tenant and facility from Session record
4. Set Shared::Current.tenant and Shared::Current.facility
5. Set PostgreSQL RLS variables (app.current_tenant_id GUC)
6. Verify user membership access

Key Methods:

def set_tenant_from_session
  session = current_session  # Finds by token hash
  @current_tenant = session.tenant
  @current_facility = session.facility
  Shared::Current.tenant = @current_tenant
  Shared::Current.facility = @current_facility
  Shared::Current.user = current_user
  Shared::Current.session = session
end

def set_rls_context
  # Set tenant context for RLS policies
  ActiveRecord::Base.connection.execute(
    "SELECT set_config('app.current_tenant_id', ?, true)",
    @current_tenant.id.to_s
  )

  # Set facility context if available
  ActiveRecord::Base.connection.execute(
    "SELECT set_config('app.current_facility_id', ?, true)",
    @current_facility.id.to_s
  ) if @current_facility
end

2a. Shared::Tenancy::RlsContext — explicit GUC API

Location: backend/domains/shared/services/tenancy/rls_context.rb

Purpose: Single source of truth for postgres app.current_tenant_id GUC management. Used by:

  • The TenantContext concern above (request boundary).
  • TenantAwareJob middleware (Sidekiq boundary).
  • Clinical::Patients::LongitudinalRecordService and Patient::Appointments::ListService (cross-tenant patient fan-out).
  • Identity::Facilities::WizardSetupService + SetupService (new-tenant wizard — see #1387).

API:

MethodUse case
RlsContext.activate(tenant)Request/job entry. Sets Shared::Current.tenant AND the GUC. MUST pair with clear.
RlsContext.with(tenant_id) { ... }Block-scoped switch. Captures + restores on exit, even under exception.
RlsContext.clearTeardown. Releases in-process tenant AND GUC.
RlsContext.current_gucDiagnostic — read the current postgres GUC value.
RlsContext.set_guc!(value) / set_guc(value)Low-level. Raising vs swallowing variants — see below.

Raising vs swallowing: set_guc! propagates every SQL error. set_guc swallows ONLY PG::InFailedSqlTransaction (already-poisoned tx, ensure paths) and connection-loss errors. Use the raising variant on ENTRY paths so a permission revoke or postgres-config drift can't silently leave RLS unset. The non-raising variant is for ensure blocks where the original exception must keep propagating.

The legacy Shared::Current#tenant= callback also writes the GUC implicitly. That callback is being phased out — new code should call RlsContext.activate / RlsContext.with explicitly. See the decouple plan for the migration roadmap.

The new-tenant wizard pattern (#1387): When a user logged into Tenant A creates Tenant B from the Connect dashboard's "New practice" flow, the dependent INSERTs into identity.organizations / facilities / user_memberships need app.current_tenant_id = B.id at insert time (RLS WITH CHECK is enforced). Identity::Facilities::WizardSetupService creates Tenant B first (no RLS on identity.tenants itself), then wraps the rest of the setup in RlsContext.with(B.id). The with ensure restores the caller's context for the response-render phase.

Sidekiq + cross-thread leak: Shared::Current is thread-local but not auto-reset between Sidekiq jobs. A job in tenant A that finishes without RlsContext.clear leaks its GUC onto the pooled connection — the next job in tenant B sees A's context. TenantAwareJob concern handles teardown; non-standard job paths (rake tasks, console scripts) must pair activate / clear themselves.

3. SwitchProfileService

Location: backend/domains/identity/services/profiles/switch_profile_service.rb

Purpose: Handles tenant/facility profile switching with new token issuance.

Flow:

1. Validate user has active membership in target tenant
2. Validate facility access (if facility_id provided)
3. Deactivate old ProfileSwitch record
4. Create new ProfileSwitch record
5. Issue NEW tokens with full tenant/facility context in JWT
6. Update Session with new context and token hashes

Token Issuance:

def issue_new_tokens_with_context
  # NEW access token with FULL context
  @access_token = Identity::IdentityTokenIssuer.issue_access_token(
    user: @user,
    tenant: @membership.tenant,
    membership: @membership,
    facility_id: @target_facility_id
  )

  # NEW refresh token with tenant_id
  refresh_payload = {
    user_id: @user.id,
    tenant_id: @membership.tenant.id
  }
  @refresh_token = Identity::IdentityTokenIssuer.issue_refresh_token(refresh_payload)
end

Session Update:

def update_session_context
  @session.update!(
    tenant: @membership.tenant,
    facility: @facility,
    auth_token_hash: Digest::SHA256.hexdigest(@access_token),
    refresh_token_id: @refresh_token_record.id,
    last_activity_at: Time.current
  )
end

Authentication Flows

Flow 1: Standard Login (Tenant-Agnostic)

Endpoint: POST /identity/sessions

Request:

{
  "user_signin_form": {
    "email": "user@example.com",
    "password": "password123"
  },
  "device_details": {
    "device_type": "web",
    "device_token": "unique-device-id"
  }
}

Processing (AuthenticationOrganizer):

1. PrepareUserService - Find user by email/phone
2. CheckTwoFactorService - Check 2FA status
3. ProcessUserService - Record login
4. SaveDeviceService - Save device info
5. CreateAuthSessionService - Issue tokens, create session

Response (Tenant-Agnostic JWT):

{
  "user": { "id": "...", "email": "...", ... },
  "auth_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYWJjLTEyMyIsImV4cCI6MTczNzM5MjAwMH0...",
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "available_profiles": [
    {
      "tenant": { "id": "...", "name": "City Hospital", "slug": "city-hospital" },
      "role": "admin",
      "is_primary": true
    },
    {
      "tenant": { "id": "...", "name": "Clinic XYZ", "slug": "clinic-xyz" },
      "role": "practitioner",
      "is_primary": false
    }
  ],
  "session_id": "session-uuid"
}

Database State:

-- identity.sessions
INSERT INTO identity.sessions (
  user_id, device_id, tenant_id, facility_id,
  auth_token_hash, refresh_token_id, status,
  last_activity_at, expires_at, metadata
) VALUES (
  'user-uuid', 'device-uuid', 'primary-tenant-uuid', NULL,
  'SHA256(access_token)', 'refresh-token-uuid', 'ACTIVE',
  NOW(), NOW() + 24 hours, '{}'
);

Flow 2: Profile Switch (Tenant-Specific)

Endpoint: POST /identity/profiles/switch

Request:

{
  "target_context": {
    "tenant_id": "target-tenant-uuid",
    "facility_id": "target-facility-uuid"
  }
}

Processing (SwitchProfileService):

1. Validate user membership in target tenant
2. Validate practitioner facility access (if applicable)
3. Deactivate old ProfileSwitch record
4. Create new ProfileSwitch record
5. Issue NEW tokens with tenant_id + facility_id in JWT
6. Update Session with new context

Response (Tenant-Specific JWT):

{
  "profile_switch": {
    "id": "profile-switch-uuid",
    "tenant_id": "target-tenant-uuid",
    "facility_id": "target-facility-uuid",
    "user_role": "admin",
    "switched_at": "2025-01-20T10:30:00Z"
  },
  "context": {
    "tenant": {
      "id": "target-tenant-uuid",
      "name": "City Hospital",
      "slug": "city-hospital"
    },
    "facility": {
      "id": "target-facility-uuid",
      "name": "Main Building"
    },
    "role": "admin",
    "session": {
      "id": "session-uuid",
      "expires_at": "2025-01-21T10:30:00Z"
    }
  },
  "tokens": {
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYWJjLTEyMyIsInRlbmFudF9pZCI6InRhcmdldC10ZW5hbnQtdXVpZCIsImZhY2lsaXR5X2lkIjoidGFyZ2V0LWZhY2lsaXR5LXV1aWQiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MzczOTIwMDAwfQ...",
    "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
    "token_type": "Bearer",
    "expires_in": 86400,
    "session_id": "session-uuid"
  }
}

Database State:

-- identity.sessions (UPDATED)
UPDATE identity.sessions SET
  tenant_id = 'target-tenant-uuid',
  facility_id = 'target-facility-uuid',
  auth_token_hash = 'SHA256(new_access_token)',
  refresh_token_id = 'new-refresh-token-uuid',
  last_activity_at = NOW()
WHERE id = 'session-uuid';

-- identity.profile_switches (NEW RECORD)
INSERT INTO identity.profile_switches (
  session_id, user_id, user_role, tenant_id, facility_id,
  is_active, switched_at, switched_from_tenant_id, switched_from_facility_id
) VALUES (
  'session-uuid', 'user-uuid', 'admin',
  'target-tenant-uuid', 'target-facility-uuid',
  true, NOW(), 'old-tenant-uuid', 'old-facility-uuid'
);

Flow 3: Token Refresh

Endpoint: POST /identity/sessions/refresh

Request:

{
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Processing:

1. Validate refresh token (hash, expiration, revocation)
2. Find user and device from refresh token
3. Issue new access token (same tenant context as original)
4. Rotate refresh token (invalidate old one)
5. Update session with new token hashes

Row Level Security (RLS)

RLS Session Variables

The system sets PostgreSQL session variables that are used by RLS policies:

-- Set by TenantContext concern for each request
SELECT set_config('app.current_tenant_id', 'tenant-uuid', true);
SELECT set_config('app.current_facility_id', 'facility-uuid', true);

Example RLS Policy

-- Policy: clinical.patients table
CREATE POLICY tenant_facility_isolation ON clinical.patients
    USING (
      tenant_id = current_setting('app.current_tenant_id')::uuid
      AND facility_id = COALESCE(
          current_setting('app.current_facility_id')::uuid,
          facility_id
        )
    );

Benefits

  1. Performance: No joins needed for tenant filtering
  2. Security: Database-level enforcement, not app-level
  3. Simplicity: Queries don't need tenant WHERE clauses
  4. Multi-Facility: Direct facility-level isolation

Session Model

Location: backend/domains/identity/models/session.rb

Schema:

# identity.sessions
belongs_to :user, class_name: 'Identity::User'
belongs_to :device, class_name: 'Identity::Device'
belongs_to :tenant, class_name: 'Identity::Tenant', optional: true
belongs_to :facility, class_name: 'Identity::Facility', optional: true

Key Fields:

FieldTypePurpose
user_idUUIDSession owner
device_idUUIDDevice for this session
tenant_idUUID (nullable)Tenant context
facility_idUUID (nullable)Facility context
auth_token_hashStringSHA256 of current access token
refresh_token_idUUIDReference to refresh token
statusEnumACTIVE, REVOKED, EXPIRED
last_activity_atTimestampLast API activity
expires_atTimestampSession expiration

Scopes:

Identity::Session.active                        # Status = ACTIVE
Identity::Session.for_user(user)                 # By user
Identity::Session.for_tenant(tenant)             # By tenant
Identity::Session.for_facility(facility)         # By facility
Identity::Session.find_valid_session(token_hash) # Validate token

Today's Implementation Changes

1. Two-Mode JWT Architecture

Change: Login now issues tenant-agnostic JWT, profile switch issues tenant-specific JWT.

Before:

# Old: Always included tenant_id in JWT
payload = {
  user_id: user.id,
  tenant_id: tenant.id,
  membership_id: membership.id,
  role: membership.role,
  exp: 24.hours.from_now.to_i
}

After (Login):

# New: Only user_id for login
@access_token = Identity::IdentityTokenIssuer.issue_access_token(
  user: @user
  # No tenant/membership - JWT is tenant-agnostic
)

After (Profile Switch):

# New: Full context for profile switch
@access_token = Identity::IdentityTokenIssuer.issue_access_token(
  user: @user,
  tenant: @membership.tenant,
  membership: @membership,
  facility_id: @target_facility_id
)

2. Facility ID Support

Added: facility_id column to identity.sessions table

Migration: 20260120115911_add_facility_id_to_identity_sessions.rb

ALTER TABLE identity.sessions ADD COLUMN facility_id uuid;
CREATE INDEX idx_id_sessions_facility ON identity.sessions(facility_id);

Model Update:

class Session < BaseRecord
  belongs_to :facility, class_name: 'Identity::Facility', optional: true
  scope :for_facility, ->(facility) { where(facility: facility) }
end

3. TenantContext from Session

Changed: Tenant context extracted from Session record, not JWT.

Before:

# Old: Extract tenant_id from JWT
decoded = JWT.decode(token, secret_key)
@current_tenant = Tenant.find(decoded['tenant_id'])

After:

# New: Extract tenant from Session record
session = Identity::Session.find_valid_session(token_hash)
@current_tenant = session.tenant
@current_facility = session.facility

4. Centralized Token Issuance

Created: TokenIssuanceService as single source of truth.

Benefits:

  • Consistent token generation across all flows
  • Session creation and update in one place
  • Easy to modify token logic
  • Reduced code duplication

Used By:

  • Login: CreateAuthSessionService
  • Refresh: RefreshTokenService
  • Verification: VerificationWithProfileService
  • Profile Switch: SwitchProfileService

5. Duplicate Session Fix

Fixed: ProcessUserService was calling TokenIssuanceService directly, creating duplicate sessions.

Solution: Removed direct call, now only CreateAuthSessionService creates sessions.

Organizer Flow (Fixed):

AuthenticationOrganizer
├── PrepareUserService      # Validate credentials
├── CheckTwoFactorService   # Check 2FA
├── ProcessUserService      # Record login ONLY (no tokens)
├── SaveDeviceService       # Save device
└── CreateAuthSessionService # Create session + tokens (single source)

6. API Response Unification

Updated: 2FA verify endpoint now matches login response format.

Added: available_profiles to 2FA verify response.

# TwoFactorController#verify
available_profiles_result = Identity::Profiles::GetAvailableProfilesService.call(
  user: @user
)

render_blueprint(
  @user,
  Identity::UserBlueprint,
  view: :with_tokens,
  extras: {
    auth_token: data[:auth_token],
    refresh_token: data[:refresh_token],
    available_profiles: available_profiles_result.data[:available_profiles]
  }
)

7. Refresh Token Consistency

Updated: Refresh token for login is now tenant-agnostic, matching access token pattern.

Before:

refresh_payload = {
  user_id: @user.id,
  tenant_id: @tenant&. id  # Included in JWT
}

After:

refresh_payload = {
  user_id: @user.id
  # Note: NOT including tenant_id since access token doesn't have it
}

Rationale: Keeps refresh token consistent with access token. If access token can work across tenants, so should refresh token.


Security Considerations

Token Security

Token TypeAlgorithmExpirationStorage
Access TokenHS25624 hoursStateless (JWT)
Refresh TokenRS25630 daysHashed in DB

Session Validation

# Validates both token and session
def current_session
  token = request.headers['Authorization']&.split(' ')&.last
  token_hash = Digest::SHA256.hexdigest(token)

  Identity::Session.find_valid_session(token_hash)
  # Returns nil if token hash doesn't match or session expired/revoked
end

Tenant Access Verification

def verify_tenant_access!
  return unless current_user && @current_tenant

  unless current_user.member_of?(@current_tenant)
    raise Shared::Errors::TenantAccessDeniedError.new(@current_tenant.id)
  end
end

RLS Context Cleanup

def clear_rls_context
  # Clear after each request to prevent connection pool leakage
  ActiveRecord::Base.connection.execute(
    "SELECT set_config('app.current_tenant_id', '', true)"
  )
  ActiveRecord::Base.connection.execute(
    "SELECT set_config('app.current_facility_id', '', true)"
  )
ensure
  Shared::Current.reset  # Always reset thread-local context
end

API Endpoints Reference

Authentication

EndpointMethodPurposeResponse
/identity/sessionsPOSTLoginTenant-agnostic JWT + profiles
/identity/sessions/refreshPOSTRefresh tokenNew tokens, same context
/identity/sessions/logoutDELETELogoutInvalidate session
/identity/profilesGETList profilesAvailable tenants/roles
/identity/profiles/switchPOSTSwitch profileNew tenant-specific JWT
/identity/2fa/verifyPOSTVerify 2FATenant-agnostic JWT + profiles

Session Management

EndpointMethodPurpose
/identity/sessions/indexGETList active sessions
/identity/sessions/:idGETGet session details
/identity/sessions/revoke_allPOSTRevoke all sessions
/identity/sessions/:idDELETERevoke specific session

Key Files Reference

Services

Models

Concerns

Controllers

Shared


On this page