Files
rick-infra/docs/service-integration-guide.md
Joakim 9e570ac2a3 Add comprehensive authentik documentation and improve role configuration
- Add authentik-deployment-guide.md: Complete step-by-step deployment guide
- Add architecture-decisions.md: Document native DB vs containerized rationale
- Add authentication-architecture.md: SSO strategy and integration patterns
- Update deployment-guide.md: Integrate authentik deployment procedures
- Update security-hardening.md: Add multi-layer security documentation
- Update service-integration-guide.md: Add authentik integration examples
- Update README.md: Professional project overview with architecture benefits
- Update authentik role: Fix HTTP binding, add security configs, improve templates
- Remove unused authentik task files: containers.yml, networking.yml

Key improvements:
* Document security benefits of native databases over containers
* Document Unix socket IPC architecture advantages
* Provide comprehensive troubleshooting and deployment procedures
* Add forward auth integration patterns for services
* Fix authentik HTTP binding from 127.0.0.1 to 0.0.0.0
* Add shared memory and IPC security configurations
2025-12-13 21:04:20 +01:00

24 KiB

Service Integration Guide

This guide explains how to add new containerized services to rick-infra with PostgreSQL and Valkey/Redis access via Unix sockets.

Overview

Rick-infra provides a standardized approach for containerized services to access infrastructure services through Unix sockets, maintaining security while providing optimal performance.

Architecture Pattern

┌─────────────────────────────────────────────────────────────┐
│ Application Service (Podman Container)                     │
│                                                             │
│ ┌─────────────────┐                                         │
│ │ Your Container  │                                         │
│ │ UID: service    │  (host user namespace)                │
│ │ Groups: service,│                                         │
│ │   postgres,     │  (supplementary groups preserved)      │
│ │   valkey        │                                         │
│ └─────────────────┘                                         │
│           │                                                 │
│           └─────────────────────┐                           │
└─────────────────────────────────│───────────────────────────┘
                                  │
                  ┌───────────────▼──────────────┐
                  │ Host Infrastructure Services │
                  │                              │
                  │ PostgreSQL Unix Socket       │
                  │ /var/run/postgresql/         │
                  │                              │
                  │ Valkey Unix Socket           │
                  │ /var/run/valkey/            │
                  └──────────────────────────────┘

Prerequisites

Your service must be deployed as:

  1. Systemd user service (via Quadlet)
  2. Dedicated system user
  3. Podman container (rootless)

Step 1: User Setup

Create a dedicated system user for your service and add it to infrastructure groups:

- name: Create service user
  user:
    name: myservice
    system: true
    shell: /bin/false
    home: /opt/myservice
    create_home: true

- name: Add service user to infrastructure groups
  user:
    name: myservice
    groups:
      - postgres   # For PostgreSQL access
      - valkey     # For Valkey/Redis access
    append: true

Step 2: Container Configuration

Pod Configuration (myservice.pod)

[Unit]
Description=My Service Pod

[Pod]
PublishPort=127.0.0.1:8080:8080
PodmanArgs=--userns=host

[Service]
Restart=always
TimeoutStartSec=900

[Install]
WantedBy=default.target

Key Points:

  • --userns=host preserves host user namespace
  • Standard port publishing for network access

Container Configuration (myservice.container)

[Unit]
Description=My Service Container

[Container]
Image=my-service:latest
Pod=myservice.pod
EnvironmentFile=/opt/myservice/.env
User={{ service_uid }}:{{ service_gid }}
Annotation=run.oci.keep_original_groups=1

# Volume mounts for sockets
Volume=/var/run/postgresql:/var/run/postgresql:Z
Volume=/var/run/valkey:/var/run/valkey:Z

# Application volumes
Volume=/opt/myservice/data:/data
Volume=/opt/myservice/logs:/logs

Exec=my-service

[Service]
Restart=always

[Install]
WantedBy=default.target

Key Points:

  • Annotation=run.oci.keep_original_groups=1 preserves supplementary groups
  • Mount socket directories with :Z for SELinux relabeling
  • Use host UID/GID for the service user

Step 3: Service Configuration

PostgreSQL Connection

Use Unix socket connection strings:

# Environment variable
DATABASE_URL=postgresql://myservice@/myservice_db?host=/var/run/postgresql

# Or separate variables
DB_HOST=/var/run/postgresql
DB_USER=myservice
DB_NAME=myservice_db
# No DB_PORT needed for Unix sockets

Valkey/Redis Connection

Correct Format (avoids URL parsing issues):

# Single URL format (recommended)
CACHE_URL=unix:///var/run/valkey/valkey.sock?db=2&password=your_password

# Alternative format
REDIS_URL=redis://localhost/2?unix_socket_path=/var/run/valkey/valkey.sock

Avoid separate HOST/DB variables which can cause port parsing issues:

# DON'T USE - causes parsing problems
REDIS_HOST=unix:///var/run/valkey/valkey.sock
REDIS_DB=2

Step 4: Database Setup

Add database setup tasks to your role:

- name: Create application database
  postgresql_db:
    name: "{{ service_db_name }}"
    owner: "{{ service_db_user }}"
    encoding: UTF-8
    lc_collate: en_US.UTF-8
    lc_ctype: en_US.UTF-8
  become_user: postgres

- name: Create application database user
  postgresql_user:
    name: "{{ service_db_user }}"
    password: "{{ service_db_password }}"
    db: "{{ service_db_name }}"
    priv: ALL
  become_user: postgres

- name: Grant connect privileges
  postgresql_privs:
    db: "{{ service_db_name }}"
    role: "{{ service_db_user }}"
    objs: ALL_IN_SCHEMA
    privs: ALL
  become_user: postgres

Step 5: Service Role Template

Create an Ansible role using this pattern:

myservice/
├── defaults/main.yml
├── handlers/main.yml
├── tasks/
│   ├── main.yml
│   ├── database.yml
│   └── cache.yml
├── templates/
│   ├── myservice.env.j2
│   ├── myservice.pod
│   ├── myservice.container
│   └── myservice.caddy.j2
└── README.md

Example Environment Template

# My Service Configuration
# Generated by Ansible - DO NOT EDIT

# Database Configuration (Unix Socket)
DATABASE_URL=postgresql://{{ service_db_user }}@/{{ service_db_name }}?host={{ postgresql_unix_socket_directories }}
DB_PASSWORD={{ service_db_password }}

# Cache Configuration (Unix Socket)
CACHE_URL=unix://{{ valkey_unix_socket_path }}?db={{ service_valkey_db }}&password={{ valkey_password }}

# Application Configuration
SECRET_KEY={{ service_secret_key }}
LOG_LEVEL={{ service_log_level }}
BIND_ADDRESS={{ service_bind_address }}:{{ service_port }}

Troubleshooting

Socket Permission Issues

If you get permission denied errors:

  1. Check group membership:

    groups myservice
    # Should show: myservice postgres valkey
    
  2. Verify container annotations:

    podman inspect myservice --format='{{.Config.Annotations}}'
    # Should include: run.oci.keep_original_groups=1
    
  3. Check socket permissions:

    ls -la /var/run/postgresql/
    ls -la /var/run/valkey/
    

Connection Issues

  1. Test socket access from host:

    sudo -u myservice psql -h /var/run/postgresql -U myservice myservice_db
    sudo -u myservice redis-cli -s /var/run/valkey/valkey.sock ping
    
  2. Check URL format:

    • Use single CACHE_URL instead of separate variables
    • Include password in URL if required
    • Verify database number is correct

Container Issues

  1. Check container user:

    podman exec myservice id
    # Should show correct UID and supplementary groups
    
  2. Verify socket mounts:

    podman exec myservice ls -la /var/run/postgresql/
    podman exec myservice ls -la /var/run/valkey/
    

Best Practices

  1. Security:

    • Use dedicated system users for each service
    • Limit group memberships to required infrastructure
    • Use vault variables for secrets
  2. Configuration:

    • Use single URL format for Redis connections
    • Mount socket directories with appropriate SELinux labels
    • Include run.oci.keep_original_groups=1 annotation
  3. Deployment:

    • Test socket access before container deployment
    • Use proper dependency ordering in playbooks
    • Include database and cache setup tasks
  4. Monitoring:

    • Monitor socket file permissions
    • Check service logs for connection errors
    • Verify group memberships after user changes

Authentication Integration with Authentik

Overview

All services in rick-infra should integrate with Authentik for centralized authentication and authorization. This section covers the authentication integration patterns available.

Authentication Integration Patterns

Use Case: HTTP services that don't need to handle authentication internally

Benefits:

  • No application code changes required
  • Consistent authentication across all services
  • Centralized session management
  • Service receives user identity via HTTP headers

Implementation:

# Service role task to deploy Caddy configuration
- name: Deploy service Caddy configuration with Authentik forward auth
  template:
    src: myservice.caddy.j2
    dest: "{{ caddy_sites_enabled_dir }}/myservice.caddy"
    owner: root
    group: "{{ caddy_user }}"
    mode: '0644'
    backup: true
  notify: reload caddy
  tags: [caddy, auth]
# templates/myservice.caddy.j2
{{ service_domain }} {
    # Forward authentication to Authentik
    forward_auth https://auth.jnss.me {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
    }
    
    # Your service backend
    reverse_proxy {{ service_backend }}
    
    # Optional: Restrict access by group
    @not_authorized {
        not header Remote-Groups "*{{ required_group }}*"
    }
    respond @not_authorized "Access denied: insufficient privileges" 403
}

Service Code Example (Python Flask):

# Application receives authentication information via headers
from flask import Flask, request

app = Flask(__name__)

@app.route('/dashboard')
def dashboard():
    # Extract user information from headers (provided by Authentik)
    username = request.headers.get('Remote-User')
    user_name = request.headers.get('Remote-Name')
    user_email = request.headers.get('Remote-Email')
    user_groups = request.headers.get('Remote-Groups', '').split(',')
    
    # Authorization based on groups
    if not username:
        return "Authentication required", 401
        
    if 'service_users' not in user_groups:
        return "Access denied: insufficient privileges", 403
    
    return render_template('dashboard.html', 
                         username=username, 
                         name=user_name,
                         groups=user_groups)

@app.route('/admin')
def admin():
    user_groups = request.headers.get('Remote-Groups', '').split(',')
    
    if 'admins' not in user_groups:
        return "Admin access required", 403
        
    return render_template('admin.html')

Pattern 2: OAuth2/OIDC Integration

Use Case: Applications that can implement OAuth2 client functionality

Benefits:

  • Fine-grained scope control
  • API access tokens
  • Better integration with application user models
  • Support for mobile/SPA applications

Service Configuration:

# Service environment configuration
oauth2_config:
  client_id: "{{ service_oauth_client_id }}"
  client_secret: "{{ vault_service_oauth_secret }}"
  discovery_url: "https://auth.jnss.me/application/o/{{ service_slug }}/.well-known/openid_configuration"
  scopes: "openid email profile groups"
  redirect_uri: "https://{{ service_domain }}/oauth/callback"
# OAuth2 integration example (Python)
from authlib.integrations.flask_client import OAuth

oauth = OAuth(app)
oauth.register(
    'authentik',
    client_id=app.config['OAUTH_CLIENT_ID'],
    client_secret=app.config['OAUTH_CLIENT_SECRET'],
    server_metadata_url=app.config['OAUTH_DISCOVERY_URL'],
    client_kwargs={
        'scope': 'openid email profile groups'
    }
)

@app.route('/login')
def login():
    redirect_uri = url_for('oauth_callback', _external=True)
    return oauth.authentik.authorize_redirect(redirect_uri)

@app.route('/oauth/callback')
def oauth_callback():
    token = oauth.authentik.authorize_access_token()
    user_info = oauth.authentik.parse_id_token(token)
    
    # Store user information in session
    session['user'] = {
        'id': user_info['sub'],
        'username': user_info['preferred_username'],
        'email': user_info['email'],
        'name': user_info['name'],
        'groups': user_info.get('groups', [])
    }
    
    return redirect('/dashboard')

Pattern 3: API-Only Authentication

Use Case: REST APIs, microservices, machine-to-machine communication

Implementation:

# API service with token validation
import requests
from flask import Flask, request, jsonify

def validate_token(token):
    """Validate Bearer token with Authentik introspection endpoint"""
    try:
        response = requests.post(
            'https://auth.jnss.me/application/o/introspect/',
            headers={
                'Authorization': f'Bearer {app.config["API_CLIENT_TOKEN"]}'
            },
            data={'token': token},
            timeout=5
        )
        
        if response.status_code == 200:
            return response.json()
        return None
    except Exception as e:
        app.logger.error(f"Token validation error: {e}")
        return None

@app.route('/api/data')
def api_data():
    auth_header = request.headers.get('Authorization')
    
    if not auth_header or not auth_header.startswith('Bearer '):
        return jsonify({'error': 'Missing or invalid Authorization header'}), 401
    
    token = auth_header[7:]  # Remove 'Bearer ' prefix
    token_info = validate_token(token)
    
    if not token_info or not token_info.get('active'):
        return jsonify({'error': 'Invalid or expired token'}), 401
    
    # Extract user information from token
    username = token_info.get('username')
    scope = token_info.get('scope', '').split()
    
    # Check required scope
    if 'api:read' not in scope:
        return jsonify({'error': 'Insufficient permissions'}), 403
    
    return jsonify({
        'message': f'Hello {username}',
        'data': 'Your API response data here'
    })

Authentik Provider Configuration

For each service integration, configure the appropriate provider in Authentik:

Forward Auth Provider (Pattern 1)

# Authentik provider configuration (via admin interface)
provider_config:
  name: "{{ service_name }} Forward Auth"
  type: "Proxy Provider"
  authorization_flow: "default-provider-authorization-implicit-consent"
  external_host: "https://{{ service_domain }}"
  internal_host: "http://localhost:{{ service_port }}"
  skip_path_regex: "^/(health|metrics|static).*"

OAuth2 Provider (Pattern 2)

# Authentik OAuth2 provider configuration
oauth_provider_config:
  name: "{{ service_name }} OAuth2"
  type: "OAuth2/OpenID Provider"
  authorization_flow: "default-provider-authorization-explicit-consent"
  client_type: "confidential"
  client_id: "{{ service_oauth_client_id }}"
  redirect_uris: 
    - "https://{{ service_domain }}/oauth/callback"
  post_logout_redirect_uris:
    - "https://{{ service_domain }}/"

API Provider (Pattern 3)

# Authentik API provider configuration  
api_provider_config:
  name: "{{ service_name }} API"
  type: "OAuth2/OpenID Provider" 
  authorization_flow: "default-provider-authorization-implicit-consent"
  client_type: "confidential"
  client_id: "{{ service_api_client_id }}"
  include_claims_in_id_token: true
  issuer_mode: "per_provider"

Group-Based Authorization

Service-Specific Groups

# Create service-specific groups in Authentik
service_groups:
  - name: "{{ service_name }}_users"
    description: "Users who can access {{ service_name }}"
    is_superuser: false
    
  - name: "{{ service_name }}_admins"
    description: "Administrators for {{ service_name }}"
    is_superuser: false
    parent: "{{ service_name }}_users"
    
  - name: "{{ service_name }}_readonly"
    description: "Read-only access to {{ service_name }}"
    is_superuser: false
    parent: "{{ service_name }}_users"

Policy-Based Access Control

# Authentik policies for service access
service_policies:
  - name: "{{ service_name }} Group Access"
    policy_type: "Group Membership Policy"
    groups: ["{{ service_name }}_users"]
    
  - name: "{{ service_name }} Business Hours"
    policy_type: "Time-based Policy"
    parameters:
      start_time: "08:00"
      end_time: "18:00"
      days: ["monday", "tuesday", "wednesday", "thursday", "friday"]
      
  - name: "{{ service_name }} IP Restriction"
    policy_type: "Source IP Policy" 
    parameters:
      cidr: "10.0.0.0/8"

Service Role Template with Authentication

Here's a complete service role template that includes authentication integration:

# roles/myservice/defaults/main.yml
---
# Service configuration
service_name: "myservice"
service_domain: "myservice.jnss.me"
service_port: 8080
service_backend: "localhost:{{ service_port }}"

# Authentication configuration
auth_enabled: true
auth_pattern: "forward_auth"  # forward_auth, oauth2, api_only
required_group: "myservice_users"

# OAuth2 configuration (if auth_pattern is oauth2)
oauth_client_id: "{{ service_name }}-oauth-client"
oauth_client_secret: "{{ vault_myservice_oauth_secret }}"

# Dependencies
postgresql_db_name: "{{ service_name }}"
valkey_db_number: 2
# roles/myservice/tasks/main.yml
---
- name: Create service user and setup
  include_tasks: user.yml
  tags: [user, setup]

- name: Setup database access
  include_tasks: database.yml
  tags: [database, setup]

- name: Setup cache access  
  include_tasks: cache.yml
  tags: [cache, setup]

- name: Deploy service configuration
  template:
    src: myservice.env.j2
    dest: "{{ service_home }}/.env"
    owner: "{{ service_user }}"
    group: "{{ service_group }}"
    mode: '0600'
  tags: [config]

- name: Deploy container configuration
  template:
    src: "{{ item.src }}"
    dest: "{{ service_quadlet_dir }}/{{ item.dest }}"
    owner: "{{ service_user }}"
    group: "{{ service_group }}"
    mode: '0644'
  loop:
    - { src: 'myservice.pod', dest: 'myservice.pod' }
    - { src: 'myservice.container', dest: 'myservice.container' }
  become: true
  become_user: "{{ service_user }}"
  notify: 
    - reload systemd user
    - restart myservice
  tags: [containers]

- name: Deploy Caddy configuration with authentication
  template:
    src: myservice.caddy.j2
    dest: "{{ caddy_sites_enabled_dir }}/{{ service_name }}.caddy"
    owner: root
    group: "{{ caddy_user }}"
    mode: '0644'
    backup: true
  notify: reload caddy
  tags: [caddy, auth]
  when: auth_enabled

- name: Start and enable service
  systemd:
    name: "{{ service_name }}"
    enabled: true
    state: started
    scope: user
    daemon_reload: true
  become: true
  become_user: "{{ service_user }}"
  tags: [service]
# roles/myservice/templates/myservice.caddy.j2
{{ service_domain }} {
{% if auth_enabled and auth_pattern == 'forward_auth' %}
    # Forward authentication to Authentik
    forward_auth https://auth.jnss.me {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
    }
    
{% if required_group %}
    # Restrict access to specific group
    @not_authorized {
        not header Remote-Groups "*{{ required_group }}*"
    }
    respond @not_authorized "Access denied: {{ required_group }} group required" 403
{% endif %}
{% endif %}

    # Service backend
    reverse_proxy {{ service_backend }}
    
    # Health check endpoint (no auth required)
    handle /health {
        reverse_proxy {{ service_backend }}
    }
}

Integration Testing

Authentication Integration Tests

# Service authentication tests
authentication_tests:
  - name: "Test unauthenticated access denied"
    uri: "https://{{ service_domain }}/"
    method: "GET"
    expected_status: [302, 401]  # Redirect to login or unauthorized
    
  - name: "Test authenticated access allowed"
    uri: "https://{{ service_domain }}/"
    method: "GET"
    headers:
      Cookie: "authentik_session={{ valid_session_cookie }}"
    expected_status: [200]
    
  - name: "Test group authorization"
    uri: "https://{{ service_domain }}/admin"
    method: "GET"  
    headers:
      Cookie: "authentik_session={{ admin_session_cookie }}"
    expected_status: [200]
    
  - name: "Test insufficient privileges"
    uri: "https://{{ service_domain }}/admin"
    method: "GET"
    headers:
      Cookie: "authentik_session={{ user_session_cookie }}"
    expected_status: [403]

Automated Testing Script

#!/bin/bash
# test-service-auth.sh

SERVICE_DOMAIN="myservice.jnss.me"

echo "Testing service authentication for $SERVICE_DOMAIN"

# Test 1: Unauthenticated access should redirect or deny
echo "Test 1: Unauthenticated access"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "https://$SERVICE_DOMAIN/")
if [[ "$RESPONSE" == "302" || "$RESPONSE" == "401" ]]; then
    echo "✓ PASS: Unauthenticated access properly denied ($RESPONSE)"
else
    echo "✗ FAIL: Unauthenticated access allowed ($RESPONSE)"
fi

# Test 2: Health endpoint should be accessible
echo "Test 2: Health endpoint access"  
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "https://$SERVICE_DOMAIN/health")
if [[ "$RESPONSE" == "200" ]]; then
    echo "✓ PASS: Health endpoint accessible"
else
    echo "✗ FAIL: Health endpoint not accessible ($RESPONSE)"
fi

# Test 3: Authentik forward auth endpoint exists
echo "Test 3: Authentik forward auth endpoint"
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "https://auth.jnss.me/outpost.goauthentik.io/auth/caddy")
if [[ "$RESPONSE" == "401" ]]; then
    echo "✓ PASS: Forward auth endpoint responding"
else
    echo "✗ FAIL: Forward auth endpoint not responding correctly ($RESPONSE)"
fi

echo "Authentication tests completed"

Example Integration

See the authentik role for a complete example of this pattern:

  • Templates: roles/authentik/templates/
  • Tasks: roles/authentik/tasks/
  • Documentation: roles/authentik/README.md

This provides a working reference implementation for Unix socket integration.

Authentication Integration Examples

For practical authentication integration examples, see:

Quick Start Checklist

When integrating a new service with rick-infra:

Infrastructure Integration

  • Create dedicated system user for the service
  • Add user to postgres and valkey groups for database access
  • Configure Unix socket connections in service environment
  • Set up Quadlet container configuration with proper user/group settings
  • Test database and cache connectivity

Authentication Integration

  • Choose authentication pattern (forward auth recommended for most services)
  • Create Caddy configuration with Authentik forward auth
  • Create Authentik provider and application configuration
  • Set up service-specific groups and policies in Authentik
  • Test authentication flow and authorization

Deployment Integration

  • Add service role dependencies in meta/main.yml
  • Configure service in site.yml with appropriate tags
  • Set up vault variables for secrets
  • Deploy and verify service functionality
  • Add monitoring and backup procedures

This comprehensive integration approach ensures all services benefit from the security, performance, and operational advantages of the rick-infra architecture.