The initial process for deploying tenant-specific UI themes was a brittle and hazardous manual affair. For each new client on our SaaS platform, a developer would clone a base SCSS theme, manually replace color and font variables, compile it, and upload the resulting CSS file to our CDN. This workflow was not just slow; it was a ticking time bomb for security and consistency. A copy-paste error could leak one tenant’s branding into another’s, and the lack of an audit trail made tracking changes impossible. The core pain point was a complete absence of programmatic control and security boundaries in a critical, customer-facing part of our application.
Our first conceptual model aimed for full automation. The goal was to store tenant branding parameters centrally and build a CI/CD pipeline that could dynamically generate and deploy a unique stylesheet for any given tenant on demand. This required a robust way to inject tenant-specific variables into a generic SCSS template at build time. The real challenge, however, lay in enforcing strict security isolation: the build process for Tenant A must be technologically incapable of accessing or affecting the assets of Tenant B.
This led to a specific technology stack selection. We chose Sass for its mature variable and mixin system, which is ideal for theming. For the security layer, we leveraged AWS Identity and Access Management (IAM), which provides the granular, policy-based access control necessary to create a secure wall between tenant build processes. The entire system would be orchestrated within a cloud-native CI/CD pipeline, initially using GitHub Actions with runners deployed on an Amazon EKS (Elastic Kubernetes Service) cluster. This combination allows us to bind IAM roles directly to our build jobs, achieving the required level of isolation.
The foundation of the architecture is the secure storage of tenant theme configurations. A relational database was our first thought, but this would introduce network dependencies and require managing database credentials within the CI/CD pipeline—a significant security overhead. A more direct and secure approach was to use AWS Systems Manager (SSM) Parameter Store. It provides a simple, hierarchical key-value store with first-class IAM integration.
We established a strict naming convention for our parameters:/saas-platform/tenants/{tenant-id}/theme/{variable-name}
For a hypothetical tenant-b7c1a9
this would look like:
-
/saas-platform/tenants/tenant-b7c1a9/theme/primary-color
:#0A74DA
-
/saas-platform/tenants/tenant-b7c1a9/theme/secondary-color
:#F0F3F4
-
/saas-platform/tenants/tenant-b7c1a9/theme/font-family
:'Inter', sans-serif
-
/saas-platform/tenants/tenant-b7c1a9/theme/border-radius-px
:4
The critical piece is the corresponding IAM policy. Each tenant’s build process would be governed by a unique role with a policy that grants read-only access exclusively to its own parameter path. In a real-world project, generating these policies should be automated, but for a single tenant, the policy is straightforward.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadTenantThemeParameters",
"Effect": "Allow",
"Action": [
"ssm:GetParametersByPath",
"ssm:GetParameters",
"ssm:GetParameter"
],
"Resource": "arn:aws:ssm:us-east-1:123456789012:parameter/saas-platform/tenants/tenant-b7c1a9/theme/*"
},
{
"Sid": "AllowWriteToTenantCDNBucket",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::saas-platform-assets/themes/tenant-b7c1a9/*"
}
]
}
This policy is the cornerstone of our security model. It explicitly allows the bearer to read SSM parameters only under the specified tenant’s path and write the final CSS only to that same tenant’s designated S3 prefix. Any attempt to access another tenant’s data would result in an immediate AccessDenied
error from the AWS API.
With the configuration store and security model defined, the next step was the build script itself. We wrote a Node.js script to act as the orchestrator. It accepts a tenantId
and outputDir
as command-line arguments, fetches the configuration from SSM, generates a dynamic SCSS variables file, invokes the Sass compiler, and logs the outcome.
Here is the core build script, build-theme.js
. It’s designed to be robust, with error handling for missing parameters or compilation failures.
// build-theme.js
const { SSMClient, GetParametersByPathCommand } = require('@aws-sdk/client-ssm');
const sass = require('sass');
const fs = require('fs').promises;
const path = require('path');
const os = a= require('os');
const REGION = process.env.AWS_REGION || 'us-east-1';
/**
* A simple logger. In a real project, this would be a more robust logging library.
*/
const logger = {
info: (message) => console.log(`[INFO] ${message}`),
error: (message, error) => console.error(`[ERROR] ${message}`, error),
warn: (message) => console.warn(`[WARN] ${message}`),
};
/**
* Fetches theme parameters for a given tenant from AWS SSM Parameter Store.
* @param {SSMClient} ssmClient - The AWS SSM client instance.
* @param {string} tenantId - The ID of the tenant.
* @returns {Promise<Map<string, string>>} A map of SCSS variable names to their values.
*/
async function fetchThemeParameters(ssmClient, tenantId) {
const parameterPath = `/saas-platform/tenants/${tenantId}/theme/`;
logger.info(`Fetching theme parameters from path: ${parameterPath}`);
const command = new GetParametersByPathCommand({
Path: parameterPath,
WithDecryption: true,
Recursive: true,
});
try {
const response = await ssmClient.send(command);
if (!response.Parameters || response.Parameters.length === 0) {
throw new Error(`No parameters found for tenant ${tenantId} at path ${parameterPath}. Check configuration.`);
}
const themeVariables = new Map();
for (const param of response.Parameters) {
// Extracts 'primary-color' from '/saas-platform/tenants/tenant-id/theme/primary-color'
const key = path.basename(param.Name);
// Append 'px' to numeric values for valid CSS units, a common requirement.
const value = /^\d+$/.test(param.Value) ? `${param.Value}px` : param.Value;
themeVariables.set(key, value);
}
logger.info(`Successfully fetched ${themeVariables.size} parameters for tenant ${tenantId}.`);
return themeVariables;
} catch (err) {
logger.error(`Failed to fetch SSM parameters for tenant ${tenantId}.`, err);
throw err; // Re-throw to fail the build process
}
}
/**
* Generates a temporary SCSS file containing the theme variables.
* @param {Map<string, string>} variables - The theme variables.
* @returns {Promise<string>} The path to the generated variables file.
*/
async function generateVariablesFile(variables) {
let scssContent = `// Auto-generated by build-theme.js at ${new Date().toISOString()}\n\n`;
// Convert map to SCSS variable declarations
for (const [key, value] of variables.entries()) {
const scssVariable = `$${key.replace(/-/g, '_')}`; // e.g., primary-color -> $primary_color
scssContent += `${scssVariable}: ${value};\n`;
}
try {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'theme-'));
const filePath = path.join(tempDir, '_variables.scss');
await fs.writeFile(filePath, scssContent);
logger.info(`Generated temporary variables file at: ${filePath}`);
return filePath;
} catch(err) {
logger.error('Failed to write temporary SCSS variables file.', err);
throw err;
}
}
/**
* Compiles the main SCSS file into CSS.
* @param {string} entryPoint - The path to the main SCSS file.
* @param {string} variablesFilePath - The path to the generated variables file.
* @param {string} outputDir - The directory to save the compiled CSS.
* @param {string} tenantId - The tenant ID, used for the output filename.
* @returns {Promise<string>} The path to the compiled CSS file.
*/
async function compileSass(entryPoint, variablesFilePath, outputDir, tenantId) {
const outputFile = path.join(outputDir, `${tenantId}.bundle.css`);
logger.info(`Compiling ${entryPoint} to ${outputFile}`);
try {
const result = sass.compile(entryPoint, {
style: 'compressed', // Minify for production
loadPaths: [path.dirname(variablesFilePath)] // Ensure @import can find the temp file
});
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(outputFile, result.css);
logger.info(`Sass compilation successful. Output: ${outputFile}`);
return outputFile;
} catch (err) {
logger.error(`Sass compilation failed for tenant ${tenantId}.`, err);
throw err;
}
}
/**
* Main execution function.
*/
async function main() {
const [tenantId, outputDir] = process.argv.slice(2);
if (!tenantId || !outputDir) {
logger.error('Usage: node build-theme.js <tenantId> <outputDir>');
process.exit(1);
}
// This is the main entry point of our theme SCSS files.
// It assumes a file structure where this script is in a `scripts/` directory.
const scssEntryPoint = path.resolve(__dirname, '../src/styles/main.scss');
const ssmClient = new SSMClient({ region: REGION });
let variablesFilePath = null;
try {
const themeVariables = await fetchThemeParameters(ssmClient, tenantId);
variablesFilePath = await generateVariablesFile(themeVariables);
await compileSass(scssEntryPoint, variablesFilePath, outputDir, tenantId);
} catch (error) {
logger.error('Build process failed.', error.message);
process.exit(1);
} finally {
// Cleanup the temporary variables file.
if (variablesFilePath) {
try {
await fs.rm(path.dirname(variablesFilePath), { recursive: true, force: true });
logger.info(`Cleaned up temporary directory: ${path.dirname(variablesFilePath)}`);
} catch (cleanupError) {
logger.warn('Failed to clean up temporary files.', cleanupError);
}
}
}
}
if (require.main === module) {
main();
}
Our main Sass entry point, src/styles/main.scss
, is designed to be generic. It imports the dynamically generated variables file and uses those variables to style components.
// src/styles/main.scss
// This file is dynamically generated by the build script and contains tenant-specific values.
// The build script ensures this import resolves correctly.
@import 'variables';
// A base style reset or framework can be included here.
@import 'normalize';
// Generic component styles that consume the theme variables.
.button-primary {
background-color: $primary_color;
color: white;
border-radius: $border_radius_px;
padding: 10px 20px;
border: none;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
.card {
border: 1px solid $secondary_color;
border-radius: $border_radius_px;
font-family: $font_family;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
A common pitfall is testing such a script, as it has a hard dependency on cloud services. To enable local development and unit testing, we used Jest to mock the AWS SDK. This allows us to verify the script’s logic without making actual API calls.
// scripts/build-theme.test.js
const { SSMClient, GetParametersByPathCommand } = require('@aws-sdk/client-ssm');
const sass = require('sass');
const fs = require('fs').promises;
// Mock the AWS SDK
jest.mock('@aws-sdk/client-ssm');
// Mock the sass compiler
jest.mock('sass');
// Mock fs.promises for file system operations
jest.mock('fs', () => ({
...jest.requireActual('fs'),
promises: {
writeFile: jest.fn(),
mkdir: jest.fn().mockResolvedValue(undefined),
mkdtemp: jest.fn().mockResolvedValue('/tmp/theme-xyz'),
rm: jest.fn(),
}
}));
// Manually require the script we want to test
const { fetchThemeParameters, generateVariablesFile, compileSass } = require('./build-theme-module'); // Assuming main logic is exported from a module file
describe('Theme Build Process', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
SSMClient.prototype.send = jest.fn();
});
test('fetchThemeParameters should retrieve and format variables correctly', async () => {
const mockSsmClient = new SSMClient({});
const mockResponse = {
Parameters: [
{ Name: '/saas-platform/tenants/test-tenant/theme/primary-color', Value: '#ff0000' },
{ Name: '/saas-platform/tenants/test-tenant/theme/border-radius-px', Value: '5' },
]
};
SSMClient.prototype.send.mockResolvedValue(mockResponse);
const variables = await fetchThemeParameters(mockSsmClient, 'test-tenant');
expect(variables.get('primary-color')).toBe('#ff0000');
expect(variables.get('border-radius-px')).toBe('5px'); // Verifies unit suffix logic
expect(variables.size).toBe(2);
});
test('generateVariablesFile should create a valid SCSS string', async () => {
const variables = new Map([
['primary_color', '#123456'],
['font_family', "'Roboto'"]
]);
await generateVariablesFile(variables);
// Check what fs.writeFile was called with
const writtenContent = fs.promises.writeFile.mock.calls[0][1];
expect(writtenContent).toContain('$primary_color: #123456;');
expect(writtenContent).toContain("$font_family: 'Roboto';");
});
test('compileSass should invoke the sass compiler with correct options', async () => {
sass.compile.mockReturnValue({ css: '/* compiled css */' });
await compileSass('./main.scss', '/tmp/theme-xyz/_variables.scss', './dist', 'test-tenant');
expect(sass.compile).toHaveBeenCalledWith(
'./main.scss',
expect.objectContaining({
style: 'compressed',
loadPaths: ['/tmp/theme-xyz']
})
);
expect(fs.promises.writeFile).toHaveBeenCalledWith(
expect.stringContaining('dist/test-tenant.bundle.css'),
'/* compiled css */'
);
});
});
Note: For the test to work, the main logic from build-theme.js
would need to be exported, which is a standard refactoring practice for testability.
The final piece of the puzzle was integrating this into a CI/CD pipeline. We used GitHub Actions with self-hosted runners on an EKS cluster, leveraging IAM Roles for Service Accounts (IRSA). This is the most secure method for granting AWS permissions to pods running on EKS.
First, we need a Kubernetes ServiceAccount annotated with the ARN of the IAM role we created earlier.
# k8s-service-account.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: theme-builder-sa
namespace: cicd-runners
annotations:
# This annotation is crucial for IRSA to work.
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/TenantThemeBuilderRole-tenant-b7c1a9
The IAM role’s trust policy must be updated to allow this specific service account to assume it.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53BF8DE7E73F782F20"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53BF8DE7E73F782F20:sub": "system:serviceaccount:cicd-runners:theme-builder-sa"
}
}
}
]
}
The GitHub Actions workflow then triggers this process. It checks out the code, and runs the build script inside a container that has the AWS SDK and Node.js installed. Since the runner pod is associated with our ServiceAccount, the SDK automatically acquires the necessary credentials via IRSA.
# .github/workflows/build-tenant-theme.yaml
name: Build Tenant Theme
on:
workflow_dispatch:
inputs:
tenant_id:
description: 'The ID of the tenant to build the theme for'
required: true
iam_role_arn:
description: 'The ARN of the IAM role for this tenant build'
required: true
jobs:
build:
runs-on: self-hosted # This must target our EKS-based runners
container:
image: node:18-alpine
# The AWS SDK will automatically pick up credentials from the IRSA environment
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ github.event.inputs.iam_role_arn }}
aws-region: us-east-1
- name: Install Dependencies
run: npm ci
- name: Build Tenant Theme CSS
run: node scripts/build-theme.js ${{ github.event.inputs.tenant_id }} ./dist
- name: Upload CSS to S3
run: |
aws s3 cp ./dist/${{ github.event.inputs.tenant_id }}.bundle.css s3://saas-platform-assets/themes/${{ github.event.inputs.tenant_id }}/theme.css --acl public-read
Note: The aws-actions/configure-aws-credentials
step is shown for clarity with OIDC for GitHub Actions directly, which is an alternative to IRSA if not running on EKS. With IRSA, the environment inside the pod is often pre-configured, and this explicit step may not be necessary if the SDK is configured to check the filesystem for a web identity token.
The overall flow can be visualized as follows:
sequenceDiagram participant GH as GitHub Actions participant Pod as Runner Pod (EKS) participant K8s as Kubernetes API participant AWS_STS as AWS STS participant AWS_SSM as AWS SSM participant AWS_S3 as AWS S3 GH->>K8s: Trigger workflow, request a new pod for the job. K8s-->>Pod: Starts pod with `theme-builder-sa` ServiceAccount. Pod->>AWS_STS: (via SDK) Presents K8s OIDC token to assume role. AWS_STS-->>Pod: Returns temporary IAM credentials. Pod->>AWS_SSM: (with temp creds) GetParametersByPath for tenantId. AWS_SSM-->>Pod: Returns theme variables. Pod->>Pod: Generates `_variables.scss` and compiles Sass. Pod->>AWS_S3: (with temp creds) Upload compiled CSS to tenant's path. AWS_S3-->>Pod: Acknowledges upload.
This architecture successfully replaced a fragile manual process with a fully automated, secure, and auditable system. Onboarding a new tenant’s theme is now a matter of defining their parameters in SSM and triggering a workflow, a process that can be further automated via an internal admin panel. The use of IAM as a hard boundary between tenants provides strong security guarantees that were previously nonexistent.
The current implementation, however, is not without limitations. The entire process is build-time, meaning a change to a tenant’s theme requires a new CI/CD run. For instant theme updates, a different approach using CSS Custom Properties managed client-side would be superior, though it presents its own set of complexities. Furthermore, as the number of tenants scales into the thousands, managing individual IAM roles and SSM parameter hierarchies could become cumbersome. Future iterations should focus on managing this configuration via an Infrastructure as Code tool like Terraform, enabling version control and automated provisioning of all tenant-related resources. Finally, this solution creates a dependency on a specific cloud provider’s services; a more portable architecture might leverage a self-hosted solution like HashiCorp Vault for secrets management.