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
| Aspect | Decision | Rationale |
|---|---|---|
| JWT Format | Tenant-agnostic for login, tenant-specific for profile switch | Allows profile viewing without commitment, explicit tenant selection for actions |
| Context Storage | Session record (tenant_id, facility_id) | Enables RLS without re-issuing tokens, supports profile switching |
| Token Issuance | Centralized via TokenIssuanceService | Single source of truth, consistent behavior across flows |
| Session Management | Session reuse with context updates | Clean audit trail, no duplicate sessions |
| RLS Enforcement | PostgreSQL session variables | Database-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
end2. 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 accessKey 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
end2a. 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
TenantContextconcern above (request boundary). TenantAwareJobmiddleware (Sidekiq boundary).Clinical::Patients::LongitudinalRecordServiceandPatient::Appointments::ListService(cross-tenant patient fan-out).Identity::Facilities::WizardSetupService+SetupService(new-tenant wizard — see #1387).
API:
| Method | Use 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.clear | Teardown. Releases in-process tenant AND GUC. |
RlsContext.current_guc | Diagnostic — 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 hashesToken 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)
endSession 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
)
endAuthentication 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 sessionResponse (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 contextResponse (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 hashesRow 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
- Performance: No joins needed for tenant filtering
- Security: Database-level enforcement, not app-level
- Simplicity: Queries don't need tenant WHERE clauses
- 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: trueKey Fields:
| Field | Type | Purpose |
|---|---|---|
user_id | UUID | Session owner |
device_id | UUID | Device for this session |
tenant_id | UUID (nullable) | Tenant context |
facility_id | UUID (nullable) | Facility context |
auth_token_hash | String | SHA256 of current access token |
refresh_token_id | UUID | Reference to refresh token |
status | Enum | ACTIVE, REVOKED, EXPIRED |
last_activity_at | Timestamp | Last API activity |
expires_at | Timestamp | Session 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 tokenToday'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) }
end3. 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.facility4. 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 Type | Algorithm | Expiration | Storage |
|---|---|---|---|
| Access Token | HS256 | 24 hours | Stateless (JWT) |
| Refresh Token | RS256 | 30 days | Hashed 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
endTenant 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
endRLS 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
endAPI Endpoints Reference
Authentication
| Endpoint | Method | Purpose | Response |
|---|---|---|---|
/identity/sessions | POST | Login | Tenant-agnostic JWT + profiles |
/identity/sessions/refresh | POST | Refresh token | New tokens, same context |
/identity/sessions/logout | DELETE | Logout | Invalidate session |
/identity/profiles | GET | List profiles | Available tenants/roles |
/identity/profiles/switch | POST | Switch profile | New tenant-specific JWT |
/identity/2fa/verify | POST | Verify 2FA | Tenant-agnostic JWT + profiles |
Session Management
| Endpoint | Method | Purpose |
|---|---|---|
/identity/sessions/index | GET | List active sessions |
/identity/sessions/:id | GET | Get session details |
/identity/sessions/revoke_all | POST | Revoke all sessions |
/identity/sessions/:id | DELETE | Revoke specific session |
Key Files Reference
Services
Models
Concerns
Controllers
Shared
- Shared::Current - Request-scoped context
Related Documentation
- Authentication System Design - Original design doc
- Identity Schema - Database schema
- Background - Historical context
- Architectural Decisions - Design rationale