Security Model

Last updated on Jun 12, 2026

Understanding authentication, authorization, and security in CHRIS.

Overview

CHRIS implements multiple layers of security:

  1. Authentication - Verifying user identity (Supabase Auth)
  2. Authorization - Controlling access (RBAC + RLS)
  3. Data Protection - Securing data at rest and in transit

Authentication

Supabase Auth (GoTrue)

CHRIS uses Supabase Auth for user management:

  • Email/password authentication
  • Secure session management
  • JWT token-based API access
  • Password reset functionality

Authentication Flow

sequenceDiagram
    participant User
    participant Frontend
    participant Supabase Auth
    participant Database

    User->>Frontend: Enter credentials
    Frontend->>Supabase Auth: signInWithPassword()
    Supabase Auth->>Supabase Auth: Validate credentials
    Supabase Auth-->>Frontend: JWT Token + Refresh Token
    Frontend->>Database: API request + JWT
    Database->>Database: Validate JWT
    Database->>Database: Apply RLS policies
    Database-->>Frontend: Authorized data

JWT Tokens

Every authenticated request includes a JWT containing:

{
  "sub": "user-uuid",
  "email": "user@company.com",
  "role": "authenticated",
  "exp": 1234567890
}

Tokens expire after 1 hour and are automatically refreshed.


Authorization (RBAC)

Role-Based Access Control

Three primary roles with hierarchical permissions:

Role Level Description
admin 3 Full system access
hr_manager 2 HR operations
employee 1 Basic access

Role Storage

Roles are stored in the user_roles table:

CREATE TABLE user_roles (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES auth.users,
  role app_role NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

Role Checking

Frontend role checking via useAuth hook:

const { isAdmin, isHR, isEmployee } = useAuth();

if (isAdmin) {
  // Show admin features
}

Backend role checking via RPC functions:

SELECT get_user_role(auth.uid());
-- Returns: 'admin', 'hr_manager', or 'employee'

Row Level Security (RLS)

What is RLS?

Row Level Security enforces access control at the database level. Every query is automatically filtered based on policies.

Policy Structure

CREATE POLICY "policy_name"
ON table_name
FOR [SELECT | INSERT | UPDATE | DELETE | ALL]
[TO role_name]
USING (condition)           -- For SELECT/UPDATE/DELETE
WITH CHECK (condition);     -- For INSERT/UPDATE

Example Policies

Users can view their own profile:

CREATE POLICY "own_profile_select"
ON profiles FOR SELECT
USING (auth.uid() = id);

HR can view all profiles:

CREATE POLICY "hr_all_profiles_select"
ON profiles FOR SELECT
USING (
  EXISTS (
    SELECT 1 FROM user_roles
    WHERE user_id = auth.uid()
    AND role IN ('admin', 'hr_manager')
  )
);

Team leaders can view team requests:

CREATE POLICY "team_leader_requests"
ON leave_requests FOR SELECT
USING (
  -- Own requests
  auth.uid() = user_id
  OR
  -- Team member requests
  EXISTS (
    SELECT 1 FROM profiles p
    JOIN teams t ON p.team_id = t.id
    WHERE p.id = leave_requests.user_id
    AND t.lead_user_id = auth.uid()
  )
);

RLS Best Practices

  1. Enable RLS on all tables: ALTER TABLE name ENABLE ROW LEVEL SECURITY;
  2. Deny by default: No access without explicit policy
  3. Keep policies simple: Complex policies slow queries
  4. Test thoroughly: Verify policies work as expected

Admin Masquerade

How It Works

Admins can view the system as any user without knowing their password:

  1. Admin initiates masquerade
  2. Frontend switches to "view as" mode
  3. All data fetched is filtered for target user
  4. Actions are still logged as admin

Security Considerations

  • Audit logging: All masquerade sessions logged
  • No authentication bypass: Admin doesn't get user's password
  • Limited scope: Can only view, not authenticate as user
  • Admin only: HR managers cannot masquerade

Implementation

// Store original user
const originalUser = currentUser;

// Set masquerade target
setMasqueradeUser(targetUserId);

// Queries now filter for target user
const { data } = useQuery({
  queryKey: ['profile', masqueradeUser || currentUser],
  queryFn: () => fetchProfile(masqueradeUser || currentUser)
});

Data Protection

Encryption in Transit

  • All connections use HTTPS/TLS
  • Supabase enforces TLS 1.2+
  • API keys transmitted securely

Encryption at Rest

  • Database encrypted by Supabase
  • Storage files encrypted
  • Backups encrypted

Sensitive Data Handling

Data Type Protection
Passwords bcrypt hashed
SMTP credentials Stored encrypted
JWT tokens Short-lived, signed
Session data HttpOnly cookies

API Security

Request Authentication

Every API request must include:

Authorization: Bearer <JWT_TOKEN>
apikey: <SUPABASE_ANON_KEY>

Edge Function Security

Edge Functions verify JWT before processing:

const authHeader = req.headers.get('Authorization');
const token = authHeader?.replace('Bearer ', '');

const { data: { user }, error } = await supabase.auth.getUser(token);
if (error || !user) {
  return new Response('Unauthorized', { status: 401 });
}

Rate Limiting

Supabase provides built-in rate limiting:

  • Auth endpoints: Limited requests per IP
  • API endpoints: Based on plan limits

Security Best Practices

For Developers

  1. Never expose service role key - Only use anon key in frontend
  2. Validate all input - Use Zod for form validation
  3. Use prepared statements - Supabase client handles this
  4. Keep dependencies updated - Regular npm updates

For Administrators

  1. Limit admin accounts - Minimal admin access
  2. Review audit logs - Monitor for suspicious activity
  3. Use strong passwords - Enforce password policies
  4. Enable 2FA - When available (Supabase feature)

For Users

  1. Don't share credentials - Each user has own account
  2. Log out on shared devices - Clear sessions
  3. Report suspicious activity - Contact admin

Audit Logging

What's Logged

Event Logged Data
Profile changes Old/new values
Leave decisions Approver, timestamp
Role changes Who, when, what
Login attempts Success/failure, IP

Audit Table Structure

CREATE TABLE audit_logs (
  id UUID PRIMARY KEY,
  actor_user_id UUID NOT NULL,
  entity_type TEXT NOT NULL,
  entity_id UUID NOT NULL,
  action TEXT NOT NULL,
  old_values JSONB,
  new_values JSONB,
  created_at TIMESTAMP DEFAULT NOW()
);

Querying Audit Logs

-- Recent changes by user
SELECT * FROM audit_logs
WHERE actor_user_id = 'user-uuid'
ORDER BY created_at DESC
LIMIT 50;

-- Changes to specific record
SELECT * FROM audit_logs
WHERE entity_type = 'profiles'
AND entity_id = 'profile-uuid';