Skip to content

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

  1. Admin-managed widgets - Admins define the "master" set of widgets/services
  2. Group-based visibility - Users only see widgets their Authentik groups permit
  3. Personal layouts - Users can rearrange visible widgets without affecting others
  4. SSO-native - Groups managed in Authentik, not duplicated in app
  5. 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

  1. Tier assignment - Add friend to tier-friends group in Authentik
  2. Automatic inheritance - They get jellyfin-access, jellyseerr-access, etc.
  3. RLS filtering - Home Portal queries check required_groups against user's groups
  4. 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 / hideForUsers visibility rules per item
  • showForKeycloakUsers / hideForKeycloakUsers for 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_request in 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:

/root/scripts/migrate-app.sh home-portal

After migration, restart PostgREST to reload schema cache:

kubectl delete pod -n supabase -l app=rest

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:

upstream sent too big header while reading response header from upstream

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:

  1. tier-friends (base tier - no parent):
  2. Go to Directory → Groups → tier-friends → Groups tab
  3. Add to groups: home-portal-users, jellyfin-access, jellyseerr-access

  4. tier-family (inherits tier-friends + more):

  5. Set Parent group: tier-friends
  6. Add to groups: home-portal-admins, jellyseerr-admin

  7. tier-owner (full access):

  8. 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.

  1. Navigate to Customization → Property Mappings
  2. Click Create → Select Scope Mapping
  3. 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):

return {
    "groups": [group.name for group in request.user.ak_groups.all()]
}

  1. Click Save

Step 3: Add Scope Mapping to the Provider

  1. Navigate to Applications → Providers
  2. Click on your home-portal provider (OAuth2/OpenID)
  3. Scroll down to Advanced protocol settings
  4. Find Scopes section
  5. In the Selected Scopes list, add your new Groups Claim mapping
  6. It should appear in the dropdown after you created it in Step 2
  7. Make sure openid, profile, and email are also selected
  8. Click Update to save

Step 4: Verify Groups in Token

After completing the above, test the flow:

  1. Log out of home-portal (if logged in)
  2. Log back in through Authentik
  3. 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

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)

  1. ✅ Created groups scope mapping in Authentik (Customization → Property Mappings)
  2. ✅ Added scope mapping to home-portal provider (Applications → Providers → home-portal → Advanced → Scopes)
  3. ✅ 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

  1. ✅ Updated src/lib/auth.ts to request groups scope and capture in callbacks
  2. ✅ Added TypeScript types in src/types/next-auth.d.ts
  3. ✅ Verified session.user.groups populated (tested in browser console)
  4. ✅ 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:

cd /root/projects/home-portal
npx supabase migration new add_rbac_tables

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

  1. Group claims must come from Authentik - Never trust client-provided groups
  2. RLS is the final authority - Even if frontend hides something, RLS blocks access
  3. Admin operations double-check - API routes verify admin status server-side
  4. Audit log - Consider logging admin changes to widgets/services

Open Questions

  1. Multiple master layouts?
  2. Should users be able to choose between multiple admin-created templates?
  3. Or single master + group-based filtering?

  4. Widget-level vs category-level groups?

  5. Should groups be on individual widgets or service categories?
  6. Recommend: Both (category as default, widget can override)

  7. Guest access?

  8. Should unauthenticated users see a public dashboard?
  9. If yes, create public group and RLS policy

  10. Group hierarchy?

  11. Should admin implicitly include all groups?
  12. 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:

  1. Create migration to change column type:

    ALTER TABLE home_portal.layouts ALTER COLUMN user_id TYPE TEXT USING user_id::TEXT;
    -- Repeat for all tables with user_id
    

  2. Apply migration:

    /root/scripts/migrate-app.sh home-portal
    

  3. CRITICAL: Restart PostgREST to reload schema cache:

    kubectl delete pod -n supabase -l app=rest
    kubectl rollout status deployment/rest -n supabase
    

  4. Restart the app pod:

    kubectl rollout restart deployment/home-portal -n home-portal
    


Issue: Groups Array Empty or Missing

Symptoms: - session.user.groups is [] or undefined - Auth works but no group information

Debugging steps:

  1. Check Authentik scope mapping exists:
  2. Go to Customization → Property Mappings
  3. Look for "Groups Claim" with scope name groups

  4. Verify scope mapping is attached to provider:

  5. Go to Applications → Providers → home-portal
  6. Check Advanced protocol settings → Scopes
  7. Ensure "Groups Claim" is in the Selected Scopes

  8. Verify user has groups assigned:

  9. Go to Directory → Users → [your user]
  10. Check Groups tab

  11. Test the raw token:

    # Get a token and decode it
    echo "YOUR_JWT_TOKEN" | cut -d. -f2 | base64 -d | jq .groups
    

  12. Check NextAuth is requesting groups scope:

  13. 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:

  1. Check current image version:

    kubectl get deployment home-portal -n home-portal -o jsonpath='{.spec.template.spec.containers[0].image}'
    

  2. 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
    

  3. Verify new pod is running:

    kubectl get pods -n home-portal -w
    


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:

kubectl logs -n supabase -l app=rest --tail=20
# Look for "Schema cache loaded"


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.

kubectl delete pod -n supabase -l app=rest

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

  1. Friend visits https://portal.bogocat.com
  2. Redirected to https://auth.bogocat.com for login
  3. After login, sees only widgets with required_groups matching their groups
  4. Friend should see: Jellyfin, Jellyseerr (if configured)
  5. 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