# 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
)
})Building Enterprise R Shiny Compliance Platforms: Architecture Patterns
The production patterns that make the difference between a prototype and a certified platform
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.
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:
| 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.