Understanding authentication, authorization, and security in CHRIS.
Overview
CHRIS implements multiple layers of security:
- Authentication - Verifying user identity (Supabase Auth)
- Authorization - Controlling access (RBAC + RLS)
- 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
- Enable RLS on all tables:
ALTER TABLE name ENABLE ROW LEVEL SECURITY; - Deny by default: No access without explicit policy
- Keep policies simple: Complex policies slow queries
- Test thoroughly: Verify policies work as expected
Admin Masquerade
How It Works
Admins can view the system as any user without knowing their password:
- Admin initiates masquerade
- Frontend switches to "view as" mode
- All data fetched is filtered for target user
- 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
- Never expose service role key - Only use anon key in frontend
- Validate all input - Use Zod for form validation
- Use prepared statements - Supabase client handles this
- Keep dependencies updated - Regular npm updates
For Administrators
- Limit admin accounts - Minimal admin access
- Review audit logs - Monitor for suspicious activity
- Use strong passwords - Enforce password policies
- Enable 2FA - When available (Supabase feature)
For Users
- Don't share credentials - Each user has own account
- Log out on shared devices - Clear sessions
- 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';