Building Enterprise R Shiny Compliance Platforms: Architecture Patterns

The production patterns that make the difference between a prototype and a certified platform

Engineering
R Shiny
Architecture
Security
Modular architecture, RBAC, parameterised queries, connection pooling, and audit logging — the engineering patterns that separate production-grade R Shiny compliance platforms from fragile prototypes.
Author

BRASS Digital Lab

Published

3 April 2026

Introduction

There is a meaningful difference between a Shiny application built as a proof-of-concept and one that operates as institutional infrastructure under regulatory scrutiny. Most R developers encounter Shiny as a rapid prototyping tool — and it is excellent at that. But the patterns that produce a working demo are not the patterns that produce a platform that passes a compliance audit, handles 50 concurrent users, and is still being maintained two years after delivery.

This post documents the architecture patterns BRASS Digital Lab uses in production compliance platforms. All of them are drawn from building and certifying the OBF Compliance Monitoring Platform for Al Ain University — a 10,000+ line application that had to be right, not just functional.


1. Modular Architecture: The Non-Negotiable Foundation

The single most important architectural decision in any enterprise Shiny application is using modules, especially in a 10,000+line application this becomes very critical for code maintenance, and continuous platform improvement.

1.1 Module Structure

Every functional domain in the OBF platform is a self-contained Shiny module:

R/
├── mod_auth.R           # Authentication & MFA
├── mod_dashboard.R      # KPI compliance overview
├── mod_kpi_entry.R      # Data entry per pillar
├── mod_evidence.R       # Evidence upload & review
├── mod_workflow.R       # Approval stage management
├── mod_reports.R        # Report generation
├── mod_admin.R          # User & system management
├── utils_db.R           # Database helper functions
├── utils_rbac.R         # RBAC enforcement helpers
└── utils_audit.R        # Audit logging functions

Each module follows the standard Shiny module pattern with a UI function and a server function:

# Module UI
mod_kpi_entry_ui <- function(id) {
  ns <- NS(id)
  tagList(
    card(
      card_header("KPI Data Entry"),
      selectInput(ns("pillar"),   "KPI Pillar",     choices = NULL),
      selectInput(ns("indicator"),"KPI Indicator",   choices = NULL),
      numericInput(ns("value"),   "Submitted Value", value = NA),
      fileInput(ns("evidence"),   "Supporting Evidence", multiple = TRUE),
      actionButton(ns("submit"),  "Submit", class = "btn-primary")
    )
  )
}

# Module Server
mod_kpi_entry_server <- function(id, conn, user_session) {
  moduleServer(id, function(input, output, session) {
    
    # Only load indicators for pillars the user can access (RBAC)
    observe({
      req(user_session$role)
      pillars <- get_accessible_pillars(conn, user_session$role)
      updateSelectInput(session, "pillar", choices = pillars)
    })
    
    # Submission with parameterised query
    observeEvent(input$submit, {
      req(input$pillar, input$indicator, input$value)
      
      submit_kpi_value(
        conn    = conn,
        user_id = user_session$user_id,
        pillar  = input$pillar,
        indicator = input$indicator,
        value   = input$value
      )
    })
  })
}

The conn and user_session arguments to the server module are the application’s connection pool handle and the current user’s session data — passed as reactive objects, not global variables.


2. Database Connection Pooling

This is the most operationally critical pattern for any multi-user Shiny application using SQLite.

The problem: By default, each Shiny session that calls dbConnect() opens a new database connection. Under concurrent load, you will exhaust SQLite’s connection limits and produce database is locked errors.

The solution: A single connection pool shared across all sessions, initialised at application startup.

# global.R — Pool initialised once at app startup
library(pool)
library(DBI)
library(RSQLite)

conn <- pool::dbPool(
  drv     = RSQLite::SQLite(),
  dbname  = Sys.getenv("DB_PATH", "data/obf_platform.sqlite"),
  minSize = 2,     # Keep at least 2 connections alive
  maxSize = 10,    # Allow up to 10 concurrent connections
  idleTimeout = 300000  # Release idle connections after 5 minutes
)

# Ensure pool is closed when app stops
onStop(function() {
  pool::poolClose(conn)
})

Important: Pass conn into every module as an argument — never access the pool as a global variable inside module server functions. This keeps modules testable and deployable independently.


3. Security: Parameterised Queries Are Mandatory

SQL injection is not a theoretical risk in institutional software — it is the most common vulnerability class in data-driven applications. Every database query in a BRASS platform uses parameterised statements with no exceptions.

# ❌ NEVER do this — vulnerable to SQL injection
user_data <- dbGetQuery(conn,
  paste0("SELECT * FROM users WHERE email = '", input$email, "'")
)

# ✅ Always use parameterised queries
user_data <- pool::dbGetQuery(conn,
  "SELECT user_id, name, role FROM users WHERE email = ? AND is_active = 1",
  params = list(input$email)
)

This applies equally to INSERT, UPDATE, and DELETE statements:

# Parameterised INSERT — safe from injection
pool::dbExecute(conn,
  "INSERT INTO kpi_submissions 
   (indicator_id, term_id, submitted_value, submitted_by, submitted_at)
   VALUES (?, ?, ?, ?, datetime('now'))",
  params = list(
    input$indicator_id,
    input$term_id,
    input$submitted_value,
    user_session$user_id
  )
)

4. Role-Based Access Control (RBAC)

RBAC in a Shiny application has two layers: UI-layer (show/hide elements) and server-layer (enforce on data operations). Both are required. UI-only RBAC is not RBAC — it is a cosmetic filter that any developer can bypass.

4.1 Session Initialisation

# On login, load the user's role into the session
init_user_session <- function(conn, user_id) {
  user <- pool::dbGetQuery(conn,
    "SELECT u.user_id, u.name, u.email, r.role_name, r.permissions_json
     FROM users u
     JOIN user_roles ur ON u.user_id = ur.user_id
     JOIN roles r ON ur.role_id = r.role_id
     WHERE u.user_id = ? AND u.is_active = 1",
    params = list(user_id)
  )
  
  list(
    user_id     = user$user_id,
    name        = user$name,
    role        = user$role_name,
    permissions = jsonlite::fromJSON(user$permissions_json)
  )
}

4.2 Server-Level Enforcement

# Enforce RBAC at the server level — not just UI
observe({
  req(user_session$permissions)
  
  if (!"edit_kpi_data" %in% user_session$permissions) {
    # Disable the submit button programmatically
    shinyjs::disable("submit")
    showNotification("Insufficient permissions to submit KPI data.",
                      type = "error")
    return()
  }
})

5. Audit Logging

Every compliance platform requires a complete, tamper-evident audit trail. The pattern is simple: a dedicated log_audit_event() function called at every state-changing operation.

# utils_audit.R
log_audit_event <- function(conn, user_id, action, entity_type, entity_id,
                             before = NULL, after = NULL) {
  pool::dbExecute(conn,
    "INSERT INTO audit_log 
     (user_id, action, entity_type, entity_id, 
      before_state, after_state, logged_at, session_id)
     VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)",
    params = list(
      user_id,
      action,
      entity_type,
      entity_id,
      if (!is.null(before)) jsonlite::toJSON(before, auto_unbox = TRUE) else NA,
      if (!is.null(after))  jsonlite::toJSON(after,  auto_unbox = TRUE) else NA,
      session$token
    )
  )
}

# Usage — called after any state change
observeEvent(input$approve_submission, {
  
  # Read current state before change
  before_state <- get_submission(conn, input$submission_id)
  
  # Make the change
  approve_submission(conn, input$submission_id, user_session$user_id)
  
  # Read new state
  after_state <- get_submission(conn, input$submission_id)
  
  # Log it
  log_audit_event(
    conn        = conn,
    user_id     = user_session$user_id,
    action      = "APPROVE",
    entity_type = "kpi_submission",
    entity_id   = input$submission_id,
    before      = before_state,
    after       = after_state
  )
})

6. Environment Variable Credential Management

Never commit credentials to Git. Never. Use .Renviron for all sensitive configuration:

# .Renviron (never committed — add to .gitignore)
DB_PATH=/path/to/obf_platform.sqlite
ADMIN_EMAIL=admin@institution.ac.ae
MFA_SECRET_KEY=your-totp-secret-key-here
SHINY_SESSION_SECRET=random-session-secret
# global.R — read from environment, never hard-coded
DB_PATH      <- Sys.getenv("DB_PATH", unset = "data/obf_platform.sqlite")
ADMIN_EMAIL  <- Sys.getenv("ADMIN_EMAIL")
SESSION_KEY  <- Sys.getenv("SHINY_SESSION_SECRET")

For shinyapps.io deployment, set environment variables in the Apps → Settings → Vars panel — they are encrypted at rest.


Architecture Checklist

Use this as a pre-deployment checklist for any compliance Shiny application:

Pre-deployment Architecture Checklist
Pattern Implementation Risk if Missing
Shiny modules All functional domains in separate modules Unmaintainable codebase
Connection pooling pool::dbPool() in global.R database is locked under load
Parameterised queries DBI params = on all SQL SQL injection
RBAC — server layer Permission checks in all server handlers Unauthorised data modification
Audit logging log_audit_event() on all state changes No compliance audit trail
Environment variables .Renviron for all credentials Credentials in Git
MFA TOTP on all accounts Account compromise risk

Conclusion

Building an enterprise compliance platform in R Shiny is entirely feasible — the OBF platform is proof of that. But it requires treating Shiny as a proper application framework, not a dashboarding shortcut. Modules, connection pooling, parameterised queries, RBAC, and audit logging are not optional extras for compliance software. They are the foundation.

If you are building a compliance platform and want a code review or architecture consultation, get in touch.

📧 brassbe1982@gmail.com · 🤝 Request a consultation

Back to top