Home Portal RBAC Design Document¶
Status: In Progress (Phase 1.5 Complete, Phase 3 Next) Author: Claude Date: 2024-12-04 Last Updated: 2025-12-05 Depends on: Authentik + Supabase Federation
Implementation Status¶
| Phase | Status | Notes |
|---|---|---|
| Phase 1: Authentik Groups | ✅ Complete | Groups scope mapping configured |
| Phase 1.5: Create Groups | ✅ Complete | Tier + service groups created with inheritance |
| Phase 2: NextAuth Groups | ✅ Complete | session.user.groups working |
| Phase 3: Schema Migration | ⏳ Pending | required_groups columns |
| Phase 4: Token Exchange + RLS | ⏳ Pending | |
| Phase 5: API Updates | ⏳ Pending | |
| Phase 6: Frontend | ⏳ Pending | |
| Phase 7: Data Migration | ⏳ Pending |
VPS Integration Status:
| Component | Status | Notes |
|---|---|---|
| Caddy reverse proxy | ✅ Working | portal.bogocat.com → K8s Ingress |
| Authentik external | ✅ Working | auth.bogocat.com |
| OAuth redirect URI | ✅ Complete | portal.bogocat.com callback configured |
| Jellyseerr proxy provider | ⏳ Pending | Forward auth for requests.bogocat.com |
| Friend accounts | ⏳ Pending | Create users, assign to tier-friends |
Executive Summary¶
Add role-based access control (RBAC) to home-portal, allowing:
- Admins: Full CRUD on widgets, services, layouts
- Users: View widgets based on group membership, rearrange personal layout
- Groups: Control which widgets/services are visible (e.g., media-users sees Plex)
This builds on the existing NextAuth + Authentik integration and extends it with Authentik group claims for authorization.
Goals¶
- Admin-managed widgets - Admins define the "master" set of widgets/services
- Group-based visibility - Users only see widgets their Authentik groups permit
- Personal layouts - Users can rearrange visible widgets without affecting others
- SSO-native - Groups managed in Authentik, not duplicated in app
- Friend/Family sharing - External users access via VPS (portal.bogocat.com) with tiered permissions
Architecture Overview¶
┌─────────────────────────────────────────────────────────────────────────┐
│ Authentication Flow │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ User clicks Login │
│ ↓ │
│ NextAuth → Authentik (OIDC) │
│ ↓ │
│ Authentik returns JWT with claims: │
│ { │
│ "sub": "abc123", │
│ "email": "jake@example.com", │
│ "groups": ["admin", "media-users", "home-portal-users"], │
│ "preferred_username": "jake" │
│ } │
│ ↓ │
│ Token Exchange → Supabase Auth (maps claims to auth.jwt()) │
│ ↓ │
│ RLS policies check groups for authorization │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Group Strategy: Hybrid Tiers + Functional Groups¶
Overview¶
We use a hybrid approach combining:
- Tier groups - Relationship-based (who they are): tier-friends, tier-family
- Functional groups - Access-based (what they can see): media-access, infra-access
- Service groups - Per-service granular control: jellyfin-access, jellyseerr-access
This supports the VPS friend-sharing use case where external users access portal.bogocat.com with appropriate permissions.
Group Hierarchy¶
Authentik Groups
├── tier-owner (you)
│ └── inherits: ALL groups
│
├── tier-family
│ └── inherits: tier-friends + additional permissions
│ ├── jellyseerr-admin (can approve requests)
│ └── home-portal-admins (can manage dashboard)
│
├── tier-friends
│ └── base external access
│ ├── home-portal-users (can view dashboard)
│ ├── jellyfin-access (media streaming)
│ └── jellyseerr-access (request media)
│
└── Functional Groups (direct assignment also possible)
├── home-portal-admins
├── home-portal-users
├── media-access (parent for all media services)
│ ├── jellyfin-access
│ ├── jellyseerr-access
│ ├── overseerr-access
│ └── plex-access
└── infra-access (owner only)
├── proxmox-access
└── kubernetes-access
Butterfly Diagram: How It All Connects¶
┌─────────────────────────────────────────────────────────────┐
│ APPLICATIONS │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Jellyfin │ │Jellyseerr│ │ Sonarr │ │ Proxmox │ │
│ │ OIDC │ │ Forward │ │ Forward │ │ Forward │ ... │
│ │ + LDAP │ │ Auth │ │ Auth │ │ Auth │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└────────┼────────────┼────────────┼────────────┼────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ │ │ SERVICE GROUPS │
│ U S E R S │ │ (Apps check these in JWT) │
│ │ │ │
│ ┌───────┐ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
│ │ Jake │ │ │ │ jellyfin- │ │ jellyseerr-│ │ arr- │ │ proxmox- │ │
│ │(owner)│ │ │ │ access │ │ access │ │ access │ │ access │ │
│ └───┬───┘ │ │ └─────▲──────┘ └──────▲─────┘ └─────▲──────┘ └────▲─────┘ │
│ │ │ │ │ │ │ │ │
│ ┌───┴───┐ │ │ │ │ │ │ │
│ │ Mom │ │ └────────┼───────────────┼─────────────┼─────────────┼───────┘
│ │(family│ │ │ │ │ │
│ └───┬───┘ │ │ │ │ │
│ │ │ ┌────────┼───────────────┼─────────────┼─────────────┼───────┐
│ ┌───┴───┐ │ │ │ │ │ │ │
│ │ Bob │ │ │ ┌────┴───────────────┴────┐ ┌────┴─────────────┴────┐ │
│ │(friend│ │ │ │ │ │ │ │
│ └───────┘ │ │ │ ┌─────────────┐ │ │ │ │
│ │ │ │ │ tier-owner │──────┼───┼───────────────────────┤ │
└──────┬──────┘ │ │ │ (Jake) │ │ │ │ │
│ │ │ └─────────────┘ │ │ OWNER-ONLY │ │
│ You assign │ │ │ │ │ │ │
│ users to │ │ ▼ │ │ (Sonarr, Radarr, │ │
│ TIER groups │ │ ┌─────────────┐ │ │ Proxmox, K8s) │ │
│ only ──────────────────────────────│ tier-family │──────┤ │ │ │
│ │ │ │ (Mom) │ │ │ │ │
│ │ │ └─────────────┘ │ └───────────────────────┘ │
│ │ │ │ │ │
│ │ │ ▼ │ TIER GROUPS │
│ │ │ ┌─────────────┐ │ (Assign users here) │
└───────────────────────────────────▶│ tier-friends│──────┘ │
│ │ (Bob) │ │
│ └─────────────┘ │
│ │ │
│ │ inherits │
│ ▼ │
│ jellyfin-access, jellyseerr-access, home-portal-users │
│ │
└────────────────────────────────────────────────────────────┘
LEGEND:
───────
• TIER GROUPS (left side): Assign users to these. They inherit service groups.
• SERVICE GROUPS (middle): Apps check these. Users get them via tier inheritance.
• APPLICATIONS (top): Check JWT for service groups to authorize access.
FLOW:
─────
1. You add Bob to "tier-friends" group in Authentik
2. tier-friends automatically includes: jellyfin-access, jellyseerr-access, home-portal-users
3. Bob logs into Jellyfin (OIDC or LDAP)
4. Jellyfin sees "jellyfin-access" in Bob's groups → Access granted
5. Bob tries to access Sonarr
6. Sonarr sees no "arr-access" in Bob's groups → Access denied
AUTHENTICATION METHODS:
───────────────────────
┌─────────────────┐ ┌─────────────────┐
│ Web Browser │ │ Apple TV │
│ │ │ Roku, etc. │
│ "Sign in with │ │ │
│ SSO" │ │ Username/Pass │
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ OIDC │ │ LDAP │
│ Provider │ │ Outpost │
└────┬─────┘ └────┬─────┘
│ │
└───────────┬───────────┘
│
▼
┌──────────────┐
│ Authentik │
│ (Groups) │
└──────────────┘
Access Matrix¶
| User Type | Groups | Sees in Home Portal |
|---|---|---|
| Owner (you) | tier-owner |
Everything |
| Family | tier-family |
Media services, can manage requests, dashboard admin |
| Friends | tier-friends |
Media services (view/request only) |
| Service-specific | jellyfin-access only |
Just Jellyfin widget |
How It Works¶
- Tier assignment - Add friend to
tier-friendsgroup in Authentik - Automatic inheritance - They get
jellyfin-access,jellyseerr-access, etc. - RLS filtering - Home Portal queries check
required_groupsagainst user's groups - Result - Friend sees only media widgets, not infrastructure
Service to Group Mapping¶
| Service | Widget | required_groups | Who sees it |
|---|---|---|---|
| Jellyfin | Media Player | ['jellyfin-access'] |
Friends, Family, Owner |
| Jellyseerr | Request Media | ['jellyseerr-access'] |
Friends, Family, Owner |
| Sonarr | TV Management | ['arr-access'] |
Owner only |
| Radarr | Movie Management | ['arr-access'] |
Owner only |
| Proxmox | Infrastructure | ['proxmox-access'] |
Owner only |
| K8s Dashboard | Infrastructure | ['kubernetes-access'] |
Owner only |
VPS External Access Flow¶
Friend visits https://portal.bogocat.com
↓
Caddy (VPS) → WireGuard → K8s Ingress → Home Portal
↓
Redirect to https://auth.bogocat.com (Authentik)
↓
Friend logs in (Authentik account)
↓
JWT contains: groups: ["tier-friends", "jellyfin-access", "jellyseerr-access", "home-portal-users"]
↓
Home Portal RLS filters widgets by groups
↓
Friend sees: Jellyfin, Jellyseerr widgets only
Inspiration from Open Source¶
Dashy (docs)¶
showForUsers/hideForUsersvisibility rules per itemshowForKeycloakUsers/hideForKeycloakUsersfor SSO roles- Guest mode (read-only without login)
- Keycloak/OIDC integration with group-based access
Organizr (docs)¶
- Group hierarchy (Admin > User > Guest) with numeric IDs
- Tabs assigned minimum group level
- Server-side auth via
auth_requestin nginx - JWT contains group claims for downstream decisions
Key Pattern: Separation of Concerns¶
| Concern | Where | Example |
|---|---|---|
| Authentication | Authentik | "Who is this user?" |
| Group membership | Authentik | "User is in media-users group" |
| Visibility rules | Database | "Sonarr widget requires media-users" |
| Authorization | RLS Policy | "Can user X see widget Y?" |
| Layout | Database | "User's personal widget arrangement" |
Data Model¶
Current Schema (Simplified)¶
-- Admin-created widgets (the "catalog")
home_portal.widgets
id, layout_id, type, config, x, y, width, height, service_id
-- Admin-created services
home_portal.services
id, name, url, icon, category, description
-- User dashboard layouts
home_portal.layouts
id, user_id, name, is_default, grid_columns
Proposed Schema Changes¶
-- Add required_groups to widgets and services
ALTER TABLE home_portal.services ADD COLUMN required_groups TEXT[] DEFAULT '{}';
ALTER TABLE home_portal.categories ADD COLUMN required_groups TEXT[] DEFAULT '{}';
-- New: Master layout (admin-created templates)
CREATE TABLE home_portal.master_layouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- e.g., "Default", "Media Power User"
description TEXT,
required_groups TEXT[] DEFAULT '{}', -- Who can use this template
is_default BOOLEAN DEFAULT false, -- Default for new users
grid_columns INTEGER DEFAULT 12,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- New: Master widgets (admin-created widget definitions)
CREATE TABLE home_portal.master_widgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
master_layout_id UUID REFERENCES home_portal.master_layouts(id) ON DELETE CASCADE,
type TEXT NOT NULL,
config JSONB DEFAULT '{}',
x INTEGER NOT NULL DEFAULT 1,
y INTEGER NOT NULL DEFAULT 1,
width INTEGER NOT NULL DEFAULT 2,
height INTEGER NOT NULL DEFAULT 2,
service_id UUID REFERENCES home_portal.services(id) ON DELETE CASCADE,
required_groups TEXT[] DEFAULT '{}', -- Additional group requirements
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User layouts become personal overrides
-- (Already exists, repurpose for user customizations)
home_portal.layouts
id, user_id, master_layout_id (FK), name, ...
-- User widget positions (personal arrangement)
CREATE TABLE home_portal.user_widget_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
layout_id UUID REFERENCES home_portal.layouts(id) ON DELETE CASCADE,
master_widget_id UUID REFERENCES home_portal.master_widgets(id) ON DELETE CASCADE,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
is_hidden BOOLEAN DEFAULT false, -- User can hide widgets they have access to
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, layout_id, master_widget_id)
);
Data Flow¶
Admin creates:
master_layouts → master_widgets → (references services)
↓
required_groups: ["media-users"]
User logs in:
Authentik JWT: groups = ["media-users", "home-portal-users"]
↓
RLS filters: user sees only widgets where
required_groups ⊆ user.groups
↓
User sees: Plex, Sonarr, Radarr (has media-users)
User doesn't see: Proxmox, Kubernetes (requires admin)
↓
User can rearrange visible widgets (saved to user_widget_positions)
Prerequisites & Infrastructure Requirements¶
Before starting, ensure these infrastructure components are properly configured:
1. Database: user_id Column Type¶
CRITICAL: Authentik's sub claim is a 64-character hex string, NOT a UUID.
Example Authentik sub: 5a28d90caf07753d5caa595cf0cf4e7827bdbbe8f42ed46282f8d7496de0ca70
If your database tables have user_id UUID columns, you MUST migrate them to TEXT:
-- Migration: 20251203000000_migrate_user_id_to_text.sql
-- Change user_id from UUID to TEXT for Authentik integration
-- Drop FK constraints
ALTER TABLE home_portal.services DROP CONSTRAINT IF EXISTS services_user_id_fkey;
ALTER TABLE home_portal.categories DROP CONSTRAINT IF EXISTS categories_user_id_fkey;
ALTER TABLE home_portal.layouts DROP CONSTRAINT IF EXISTS layouts_user_id_fkey;
ALTER TABLE home_portal.widgets DROP CONSTRAINT IF EXISTS widgets_user_id_fkey;
-- Alter column types
ALTER TABLE home_portal.services ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT;
ALTER TABLE home_portal.categories ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT;
ALTER TABLE home_portal.layouts ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT;
ALTER TABLE home_portal.widgets ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT;
-- Disable RLS (using service role key with manual filtering)
ALTER TABLE home_portal.services DISABLE ROW LEVEL SECURITY;
ALTER TABLE home_portal.categories DISABLE ROW LEVEL SECURITY;
ALTER TABLE home_portal.layouts DISABLE ROW LEVEL SECURITY;
ALTER TABLE home_portal.widgets DISABLE ROW LEVEL SECURITY;
Apply the migration:
After migration, restart PostgREST to reload schema cache:
2. Nginx Ingress: Buffer Size for Large Headers¶
CRITICAL: NextAuth sessions with Authentik groups create large response headers that exceed nginx's default buffer size.
Symptom: 502 Bad Gateway on /api/auth/session endpoint
Error in ingress logs:
Fix: Add buffer size annotations to the ingress:
kubectl patch ingress home-portal -n home-portal --type=merge -p '
{
"metadata": {
"annotations": {
"nginx.ingress.kubernetes.io/proxy-buffer-size": "128k",
"nginx.ingress.kubernetes.io/proxy-buffers-number": "4"
}
}
}'
Or in the manifest:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: home-portal
namespace: home-portal
annotations:
nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
# ... other annotations
3. Deploy Script: Host-Based Builds¶
The deploy scripts have been updated to use host-based builds (no LXC containers).
Updated script: /root/tower-fleet/scripts/deploy-home-portal.sh
Usage:
# Interactive (prompts for confirmation)
/root/tower-fleet/scripts/deploy-home-portal.sh
# Auto-confirm
yes | /root/tower-fleet/scripts/deploy-home-portal.sh
# Specific version
/root/tower-fleet/scripts/deploy-home-portal.sh v1.0.27
Manual deployment (if script fails):
cd /root/projects/home-portal
# Build
docker build -t home-portal:v1.0.27 .
# Tag and push
docker tag home-portal:v1.0.27 10.89.97.201:30500/home-portal:v1.0.27
docker push 10.89.97.201:30500/home-portal:v1.0.27
# Deploy to k8s
kubectl set image deployment/home-portal \
home-portal=10.89.97.201:30500/home-portal:v1.0.27 \
-n home-portal
# Wait for rollout
kubectl rollout status deployment/home-portal -n home-portal
Authentik Configuration¶
Step 1: Create Groups¶
Navigate to Directory → Groups in Authentik Admin and create the following groups.
Tier Groups (relationship-based)¶
These are the groups you'll assign users to. Create these first:
| Group Name | Parent Group | Description |
|---|---|---|
tier-owner |
— | Full access to everything (you) |
tier-family |
tier-friends |
Extended access for family members |
tier-friends |
— | Base external access for friends |
Service Groups (access-based)¶
These control visibility of specific services. Tier groups inherit these:
| Group Name | Description | Inherited by |
|---|---|---|
home-portal-admins |
Dashboard admin (manage widgets/layouts) | tier-owner, tier-family |
home-portal-users |
Dashboard viewer (see widgets) | tier-friends |
jellyfin-access |
Jellyfin media streaming | tier-friends |
jellyseerr-access |
Request media via Jellyseerr | tier-friends |
jellyseerr-admin |
Approve/manage Jellyseerr requests | tier-family |
arr-access |
Sonarr, Radarr, Prowlarr (media management) | tier-owner |
proxmox-access |
Proxmox infrastructure | tier-owner |
kubernetes-access |
Kubernetes dashboard | tier-owner |
Setting Up Group Inheritance¶
For each tier group, configure parent groups:
- tier-friends (base tier - no parent):
- Go to Directory → Groups → tier-friends → Groups tab
-
Add to groups:
home-portal-users,jellyfin-access,jellyseerr-access -
tier-family (inherits tier-friends + more):
- Set Parent group:
tier-friends -
Add to groups:
home-portal-admins,jellyseerr-admin -
tier-owner (full access):
- Add to groups: ALL service groups
Assign yourself to tier-owner:
- Go to Directory → Users → [your user]
- Click Groups tab
- Click Add to group and select tier-owner
Step 2: Create the Groups Scope Mapping¶
This tells Authentik to include group names in the JWT token when the groups scope is requested.
- Navigate to Customization → Property Mappings
- Click Create → Select Scope Mapping
- Fill in the form:
| Field | Value |
|---|---|
| Name | Groups Claim |
| Scope name | groups |
| Description | Include user's group memberships in token |
| Expression | See below |
Expression (paste this exactly):
- Click Save
Step 3: Add Scope Mapping to the Provider¶
- Navigate to Applications → Providers
- Click on your home-portal provider (OAuth2/OpenID)
- Scroll down to Advanced protocol settings
- Find Scopes section
- In the Selected Scopes list, add your new
Groups Claimmapping - It should appear in the dropdown after you created it in Step 2
- Make sure
openid,profile, andemailare also selected - Click Update to save
Step 4: Verify Groups in Token¶
After completing the above, test the flow:
- Log out of home-portal (if logged in)
- Log back in through Authentik
- Decode the JWT to verify groups are included:
Option A: Browser DevTools
// In browser console after logging in
const session = await fetch('/api/auth/session').then(r => r.json())
console.log(session.user.groups)
Option B: Decode JWT manually
# Get the token from browser DevTools (Network tab, look for session cookie or token)
# Decode the payload (middle part between the dots)
echo "eyJhbGci...PAYLOAD_HERE...signature" | cut -d. -f2 | base64 -d | jq .
Expected output:
{
"sub": "abc123",
"email": "jake@example.com",
"groups": ["home-portal-admins", "media-users"],
"preferred_username": "jake"
}
Troubleshooting¶
Groups array is empty or missing:
- Verify the scope mapping expression is exactly as shown (no typos)
- Verify the scope mapping is added to the provider's Selected Scopes
- Verify your user is actually assigned to groups in Authentik
- Check that the app requests the groups scope (already done in Phase 2)
"Invalid scope" error:
- The scope name in the mapping must be groups (lowercase)
- The app must request scope: 'openid profile email groups'
NextAuth Configuration¶
Status: ✅ Already implemented
The NextAuth configuration has been updated in home-portal to capture groups:
Files modified:
- src/lib/auth.ts - Requests groups scope, captures groups in JWT and session callbacks
- src/types/next-auth.d.ts - TypeScript types for session.user.groups
Key changes:
1. Authorization scope includes groups: scope: 'openid profile email groups'
2. jwt callback captures token.groups from the Authentik profile
3. session callback exposes session.user.groups to the app
4. TypeScript types define groups: string[] on the session user
Usage in components:
// Server component
import { auth } from '@/lib/auth'
export default async function Page() {
const session = await auth()
const groups = session?.user.groups || []
const isAdmin = groups.includes('home-portal-admins')
// ...
}
// Client component
'use client'
import { useSession } from 'next-auth/react'
export function MyComponent() {
const { data: session } = useSession()
const groups = session?.user.groups || []
// ...
}
Supabase RLS Policies¶
Option A: Pass Groups via JWT (Recommended)¶
If using token exchange, Supabase auth.jwt() will contain Authentik claims:
-- Helper function to extract groups from JWT
CREATE OR REPLACE FUNCTION home_portal.user_groups()
RETURNS TEXT[] AS $$
SELECT COALESCE(
(auth.jwt() -> 'groups')::TEXT[],
ARRAY[]::TEXT[]
);
$$ LANGUAGE SQL STABLE;
-- Helper function to check if user has any required group
CREATE OR REPLACE FUNCTION home_portal.has_required_groups(required TEXT[])
RETURNS BOOLEAN AS $$
SELECT CASE
WHEN required IS NULL OR array_length(required, 1) IS NULL THEN true
ELSE required && home_portal.user_groups() -- && = overlap (any match)
END;
$$ LANGUAGE SQL STABLE;
-- Helper function to check admin status
CREATE OR REPLACE FUNCTION home_portal.is_admin()
RETURNS BOOLEAN AS $$
SELECT 'home-portal-admins' = ANY(home_portal.user_groups());
$$ LANGUAGE SQL STABLE;
RLS Policies¶
-- Services: Anyone can read if they have required groups
CREATE POLICY "view_services" ON home_portal.services
FOR SELECT USING (
home_portal.has_required_groups(required_groups)
);
-- Services: Only admins can modify
CREATE POLICY "admin_manage_services" ON home_portal.services
FOR ALL USING (home_portal.is_admin())
WITH CHECK (home_portal.is_admin());
-- Master widgets: Anyone can read if they have required groups
CREATE POLICY "view_master_widgets" ON home_portal.master_widgets
FOR SELECT USING (
home_portal.has_required_groups(required_groups)
);
-- Master widgets: Only admins can modify
CREATE POLICY "admin_manage_widgets" ON home_portal.master_widgets
FOR ALL USING (home_portal.is_admin())
WITH CHECK (home_portal.is_admin());
-- User positions: Users can manage their own
CREATE POLICY "manage_own_positions" ON home_portal.user_widget_positions
FOR ALL USING (auth.uid()::text = user_id)
WITH CHECK (auth.uid()::text = user_id);
Option B: Pass Groups via Request Header (Fallback)¶
If token exchange doesn't preserve groups:
-- Read groups from custom header (set by middleware)
CREATE OR REPLACE FUNCTION home_portal.user_groups()
RETURNS TEXT[] AS $$
SELECT string_to_array(
current_setting('request.headers', true)::json->>'x-user-groups',
','
);
$$ LANGUAGE SQL STABLE;
App middleware sets header:
// middleware.ts
const groups = session.user.groups?.join(',') || ''
headers.set('x-user-groups', groups)
API Design¶
Endpoints¶
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/services |
GET | User | List visible services (RLS-filtered) |
/api/services |
POST/PUT/DELETE | Admin | Manage services |
/api/layouts |
GET | User | Get master layouts user can access |
/api/layouts/[id]/widgets |
GET | User | Get widgets for layout (RLS-filtered) |
/api/user/layout |
GET/PUT | User | Get/update personal widget positions |
/api/admin/layouts |
* | Admin | Manage master layouts |
/api/admin/widgets |
* | Admin | Manage master widgets |
Example: Get User Dashboard¶
// app/api/dashboard/route.ts
export async function GET() {
const session = await auth()
if (!session) return unauthorized()
const supabase = await getSupabaseClient(session)
// RLS automatically filters by user's groups
const { data: widgets } = await supabase
.from('master_widgets')
.select(`
*,
service:services(*),
user_position:user_widget_positions(x, y, is_hidden)
`)
.order('y', 'x')
// Merge master positions with user overrides
const dashboard = widgets.map(w => ({
...w,
x: w.user_position?.[0]?.x ?? w.x,
y: w.user_position?.[0]?.y ?? w.y,
hidden: w.user_position?.[0]?.is_hidden ?? false
})).filter(w => !w.hidden)
return Response.json({ widgets: dashboard })
}
Frontend Changes¶
Admin UI¶
New admin section at /admin:
- Create/edit master layouts
- Add widgets to layouts with group requirements
- Manage services with group requirements
- Preview "as user" with specific groups
User Dashboard¶
- Shows only widgets user has access to
- Drag-and-drop to rearrange (saves to user_widget_positions)
- Reset to default button (clears user positions)
- No create/edit/delete widget buttons (admin only)
Role-Based UI Components¶
// components/AdminOnly.tsx
export function AdminOnly({ children }: { children: React.ReactNode }) {
const { data: session } = useSession()
const isAdmin = session?.user?.groups?.includes('home-portal-admins')
if (!isAdmin) return null
return <>{children}</>
}
// components/GroupRequired.tsx
export function GroupRequired({
groups,
children
}: {
groups: string[]
children: React.ReactNode
}) {
const { data: session } = useSession()
const userGroups = session?.user?.groups || []
const hasAccess = groups.some(g => userGroups.includes(g))
if (!hasAccess) return null
return <>{children}</>
}
Migration Plan / Roadmap¶
Phase 1: Authentik Groups Setup ✅ COMPLETE (scope mapping)¶
- ✅ Created groups scope mapping in Authentik (Customization → Property Mappings)
- ✅ Added scope mapping to home-portal provider (Applications → Providers → home-portal → Advanced → Scopes)
- ✅ Verified groups appear in session
Groups to create in Authentik (Phase 1.5 - do before Phase 3):
Tier groups (assign users to these):
- tier-owner - Full access (you)
- tier-family - Extended access, inherits tier-friends
- tier-friends - Base external access
Service groups (control widget visibility):
- home-portal-admins - Dashboard admin
- home-portal-users - Dashboard viewer
- jellyfin-access - Jellyfin streaming
- jellyseerr-access - Media requests
- jellyseerr-admin - Approve requests
- arr-access - Sonarr/Radarr/Prowlarr
- proxmox-access - Infrastructure
- kubernetes-access - K8s dashboard
See Authentik Configuration → Step 1 for inheritance setup.
Phase 2: NextAuth Groups ✅ COMPLETE¶
- ✅ Updated
src/lib/auth.tsto requestgroupsscope and capture in callbacks - ✅ Added TypeScript types in
src/types/next-auth.d.ts - ✅ Verified
session.user.groupspopulated (tested in browser console) - ✅ Rebuilt and deployed home-portal v1.0.26
Files modified:
- src/lib/auth.ts - Added AuthentikProfile interface, groups scope, jwt/session callbacks
- src/types/next-auth.d.ts - Extended Session.user and JWT with groups
Phase 3: Schema Migration ⏳ NEXT¶
Goal: Add database tables for RBAC
Create migration file:
Migration SQL:
-- Add required_groups to existing tables
ALTER TABLE home_portal.services ADD COLUMN IF NOT EXISTS required_groups TEXT[] DEFAULT '{}';
ALTER TABLE home_portal.categories ADD COLUMN IF NOT EXISTS required_groups TEXT[] DEFAULT '{}';
-- Master layouts (admin-created templates)
CREATE TABLE IF NOT EXISTS home_portal.master_layouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
required_groups TEXT[] DEFAULT '{}',
is_default BOOLEAN DEFAULT false,
grid_columns INTEGER DEFAULT 12,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Master widgets (admin-created widget definitions)
CREATE TABLE IF NOT EXISTS home_portal.master_widgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
master_layout_id UUID REFERENCES home_portal.master_layouts(id) ON DELETE CASCADE,
type TEXT NOT NULL,
config JSONB DEFAULT '{}',
x INTEGER NOT NULL DEFAULT 1,
y INTEGER NOT NULL DEFAULT 1,
width INTEGER NOT NULL DEFAULT 2,
height INTEGER NOT NULL DEFAULT 2,
service_id UUID REFERENCES home_portal.services(id) ON DELETE CASCADE,
required_groups TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User widget positions (personal arrangement overrides)
CREATE TABLE IF NOT EXISTS home_portal.user_widget_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
layout_id UUID REFERENCES home_portal.layouts(id) ON DELETE CASCADE,
master_widget_id UUID REFERENCES home_portal.master_widgets(id) ON DELETE CASCADE,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
is_hidden BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, layout_id, master_widget_id)
);
-- Grant permissions
GRANT ALL ON home_portal.master_layouts TO postgres, service_role;
GRANT ALL ON home_portal.master_widgets TO postgres, service_role;
GRANT ALL ON home_portal.user_widget_positions TO postgres, service_role;
Apply migration:
/root/scripts/migrate-app.sh home-portal
kubectl delete pod -n supabase -l app=rest # Reload PostgREST schema
Phase 4: RLS Helper Functions ⏳ PENDING¶
Goal: Create SQL functions for group-based authorization
-- Helper: Extract groups from JWT (or use header fallback)
CREATE OR REPLACE FUNCTION home_portal.user_groups()
RETURNS TEXT[] AS $$
SELECT COALESCE(
(auth.jwt() -> 'groups')::TEXT[],
ARRAY[]::TEXT[]
);
$$ LANGUAGE SQL STABLE;
-- Helper: Check if user has any required group
CREATE OR REPLACE FUNCTION home_portal.has_required_groups(required TEXT[])
RETURNS BOOLEAN AS $$
SELECT CASE
WHEN required IS NULL OR array_length(required, 1) IS NULL THEN true
ELSE required && home_portal.user_groups()
END;
$$ LANGUAGE SQL STABLE;
-- Helper: Check admin status
CREATE OR REPLACE FUNCTION home_portal.is_admin()
RETURNS BOOLEAN AS $$
SELECT 'home-portal-admins' = ANY(home_portal.user_groups());
$$ LANGUAGE SQL STABLE;
Phase 5: API Updates ⏳ PENDING¶
Goal: Update existing APIs and add admin endpoints
Endpoints to add/modify:
- GET /api/services - Filter by user's groups
- POST/PUT/DELETE /api/services - Admin only
- GET /api/master-layouts - Get available layouts for user
- GET /api/master-widgets - Get widgets filtered by groups
- GET/PUT /api/user/positions - Personal widget arrangement
Phase 6: Frontend Components ⏳ PENDING¶
Goal: Add role-based UI components
Components to create:
- components/AdminOnly.tsx - Wrapper that hides content from non-admins
- components/GroupRequired.tsx - Wrapper that checks group membership
- Admin UI at /admin for managing layouts/widgets/services
Phase 7: Data Migration ⏳ PENDING¶
Goal: Migrate existing data to new RBAC schema
-- Create default master layout
INSERT INTO home_portal.master_layouts (name, is_default)
VALUES ('Default', true);
-- Migrate existing widgets to master_widgets
INSERT INTO home_portal.master_widgets (master_layout_id, type, config, x, y, width, height, service_id)
SELECT
(SELECT id FROM home_portal.master_layouts WHERE name = 'Default'),
type, config, x, y, width, height, service_id
FROM home_portal.widgets
WHERE layout_id IN (SELECT id FROM home_portal.layouts LIMIT 1);
Existing Data Migration¶
-- Migrate existing services (all become public initially)
UPDATE home_portal.services
SET required_groups = '{}'
WHERE required_groups IS NULL;
-- Create default master layout from existing layouts
INSERT INTO home_portal.master_layouts (name, is_default)
SELECT 'Default', true;
-- Migrate existing widgets to master_widgets
INSERT INTO home_portal.master_widgets
(master_layout_id, type, config, x, y, width, height, service_id)
SELECT
(SELECT id FROM home_portal.master_layouts WHERE name = 'Default'),
type, config, x, y, width, height, service_id
FROM home_portal.widgets
WHERE layout_id = (
SELECT id FROM home_portal.layouts
WHERE user_id = '74e0c106-faf9-4477-bb71-fd65038f2159' -- Original admin user
LIMIT 1
);
Security Considerations¶
- Group claims must come from Authentik - Never trust client-provided groups
- RLS is the final authority - Even if frontend hides something, RLS blocks access
- Admin operations double-check - API routes verify admin status server-side
- Audit log - Consider logging admin changes to widgets/services
Open Questions¶
- Multiple master layouts?
- Should users be able to choose between multiple admin-created templates?
-
Or single master + group-based filtering?
-
Widget-level vs category-level groups?
- Should groups be on individual widgets or service categories?
-
Recommend: Both (category as default, widget can override)
-
Guest access?
- Should unauthenticated users see a public dashboard?
-
If yes, create
publicgroup and RLS policy -
Group hierarchy?
- Should
adminimplicitly include all groups? - Recommend: Yes, simplifies admin experience
Summary¶
| Before | After |
|---|---|
| Single user's widgets | Admin-managed widget catalog |
| Manual user_id filtering | RLS with group-based policies |
| No role differentiation | Admin/User roles from Authentik |
| One dashboard for all | Group-filtered dashboard per user |
| No layout customization | Personal layout positions |
Key principle: Authentik is source of truth for identity and groups. Supabase enforces access via RLS. App just renders what RLS allows.
Troubleshooting Guide¶
Issue: 502 Bad Gateway on /api/auth/session¶
Symptoms:
- Browser console shows 502 Bad Gateway for /api/auth/session
- Response body is HTML instead of JSON: <html>...
Cause: NextAuth session headers are too large for nginx's default buffer.
Solution:
kubectl patch ingress home-portal -n home-portal --type=merge -p '
{
"metadata": {
"annotations": {
"nginx.ingress.kubernetes.io/proxy-buffer-size": "128k",
"nginx.ingress.kubernetes.io/proxy-buffers-number": "4"
}
}
}'
Verify fix:
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx --tail=20 | grep "too big header"
# Should see no new occurrences
Issue: "invalid input syntax for type uuid" Error¶
Symptoms:
- API returns 500 error
- Pod logs show: invalid input syntax for type uuid: "5a28d90caf07753d5caa595cf0cf4e7827bdbbe8f42ed46282f8d7496de0ca70"
Cause: Database user_id columns are UUID type, but Authentik's sub claim is a hex string.
Solution:
-
Create migration to change column type:
-
Apply migration:
-
CRITICAL: Restart PostgREST to reload schema cache:
-
Restart the app pod:
Issue: Groups Array Empty or Missing¶
Symptoms:
- session.user.groups is [] or undefined
- Auth works but no group information
Debugging steps:
- Check Authentik scope mapping exists:
- Go to Customization → Property Mappings
-
Look for "Groups Claim" with scope name
groups -
Verify scope mapping is attached to provider:
- Go to Applications → Providers → home-portal
- Check Advanced protocol settings → Scopes
-
Ensure "Groups Claim" is in the Selected Scopes
-
Verify user has groups assigned:
- Go to Directory → Users → [your user]
-
Check Groups tab
-
Test the raw token:
-
Check NextAuth is requesting groups scope:
- In
lib/auth.ts, verify:scope: 'openid profile email groups'
Issue: App Changes Not Reflected After Deploy¶
Symptoms: - Deployed new version but old behavior persists - New code changes not visible
Cause: Production is running an old Docker image.
Solution:
-
Check current image version:
-
Rebuild and deploy:
cd /root/projects/home-portal docker build -t home-portal:v1.0.XX . docker tag home-portal:v1.0.XX 10.89.97.201:30500/home-portal:v1.0.XX docker push 10.89.97.201:30500/home-portal:v1.0.XX kubectl set image deployment/home-portal home-portal=10.89.97.201:30500/home-portal:v1.0.XX -n home-portal kubectl rollout status deployment/home-portal -n home-portal -
Verify new pod is running:
Issue: Deploy Script Fails with "container not running"¶
Symptoms:
- /root/tower-fleet/scripts/deploy-home-portal.sh fails
- Error: container '160' not running!
Cause: Old deploy script expected LXC container 160, but development moved to host-based.
Solution: Use the updated deploy script or deploy manually:
cd /root/projects/home-portal
docker build -t home-portal:v1.0.XX .
docker tag home-portal:v1.0.XX 10.89.97.201:30500/home-portal:v1.0.XX
docker push 10.89.97.201:30500/home-portal:v1.0.XX
kubectl set image deployment/home-portal home-portal=10.89.97.201:30500/home-portal:v1.0.XX -n home-portal
Diagnostic Commands¶
Check pod status and logs:
kubectl get pods -n home-portal
kubectl logs -n home-portal -l app=home-portal --tail=50
kubectl describe pod -n home-portal -l app=home-portal
Check ingress configuration:
kubectl get ingress -n home-portal home-portal -o yaml
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx --tail=50 | grep home
Check database schema:
kubectl exec -n supabase postgres-0 -- psql -U postgres -d postgres -c \
"SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'home_portal' AND table_name = 'layouts';"
Test session endpoint from inside pod:
kubectl exec -n home-portal -l app=home-portal -- wget -q -O - http://127.0.0.1:3000/api/auth/session
Check PostgREST schema cache:
Lessons Learned¶
1. Authentik User IDs Are Not UUIDs¶
Authentik's sub claim is a 64-character hex string, not a standard UUID format. Any database columns storing Authentik user IDs must be TEXT, not UUID.
2. PostgREST Caches Schema¶
After database schema changes, PostgREST must be restarted to reload its schema cache. Otherwise, it continues using the old column types and constraints.
3. Large Auth Headers Need Buffer Configuration¶
NextAuth sessions with Authentik integration produce large response headers (multiple cookies, JWT data). Nginx ingress needs increased buffer sizes to handle them.
4. Deploy Changes to Production¶
Code changes in /root/projects/home-portal don't automatically appear in production. You must:
1. Build a new Docker image
2. Push to the registry
3. Update the k8s deployment
4. Wait for rollout
5. Development is Now Host-Based¶
All Next.js development runs directly on the Proxmox host in /root/projects/. LXC containers are no longer used for development. Deploy scripts have been updated accordingly.
VPS Friend Sharing Quick Setup¶
Once RBAC implementation is complete, here's how to onboard a friend:
1. Create Friend Account in Authentik¶
Authentik Admin UI (https://auth.bogocat.com/if/admin/)
→ Directory → Users → Create
- Username: friend-bob
- Email: bob@example.com
- Set password or send invite
→ Directory → Users → friend-bob → Groups
- Add to group: tier-friends
2. Verify OAuth Redirect URI (one-time)¶
Authentik Admin UI
→ Applications → Providers → home-portal
→ Redirect URIs/Origins (Regex)
- Add: https://portal.bogocat.com/api/auth/callback/authentik
3. Test Friend Access¶
- Friend visits
https://portal.bogocat.com - Redirected to
https://auth.bogocat.comfor login - After login, sees only widgets with
required_groupsmatching their groups - Friend should see: Jellyfin, Jellyseerr (if configured)
- Friend should NOT see: Sonarr, Radarr, Proxmox, K8s
4. Jellyseerr Forward Auth (optional)¶
To protect requests.bogocat.com with Authentik:
Authentik Admin UI
→ Applications → Providers → Create → Proxy Provider
- Name: jellyseerr-proxy
- Authorization flow: default-provider-authorization-implicit-consent
- External host: https://requests.bogocat.com
- Mode: Forward auth (single application)
→ Applications → Create
- Name: Jellyseerr
- Slug: jellyseerr
- Provider: jellyseerr-proxy
→ Outposts → authentik Embedded Outpost
- Add application: Jellyseerr
Update Caddyfile on VPS:
requests.bogocat.com {
forward_auth 10.89.97.220:80 {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
header_up Host auth.bogocat.com
header_up X-Original-URL https://requests.bogocat.com{uri}
}
reverse_proxy 10.89.97.220:80 {
header_up Host jellyseerr.internal
header_up X-Forwarded-Proto https
}
}
5. Upgrade Friend to Family¶
Authentik Admin UI
→ Directory → Users → friend-bob → Groups
- Remove from: tier-friends
- Add to: tier-family
They now get additional access (jellyseerr-admin, home-portal-admins).
References¶
- Authentik + Supabase Federation - Token exchange setup
- VPS Reverse Proxy - Caddy + WireGuard setup
- Authentik Forward Auth - Proxy provider pattern
- Dashy Authentication - Visibility rules pattern
- Organizr Server Auth - Group hierarchy
- Supabase RLS - Policy syntax
- Authentik OIDC Claims - Groups in JWT