Security
Authentication, authorization, RBAC, encryption at rest, and audit logging.
This section consolidates all security concerns across the Courier platform: authentication, authorization, data protection at rest and in transit, secrets management, and hardening. Many security decisions are described in detail in their respective subsystem sections — this section provides the unified view and fills gaps.
12.1 Authentication
Courier uses a hybrid local-first + SSO-optional authentication model. Local username/password authentication works out of the box on first deployment, with no external identity provider required. Administrators can optionally configure enterprise SSO providers (OIDC, SAML) through the settings UI.
12.1.1 First-Run Setup
On first startup, a SetupGuardMiddleware blocks all API requests (except /api/v1/setup/*, /api/v1/auth/*, /health, /swagger) with 503 Service Unavailable until initial setup is completed. The frontend redirects to /setup, where the administrator creates the first admin account.
Setup flow:
- Frontend detects
auth.setup_completed = falseviaGET /api/v1/setup/statusand redirects to/setup - Administrator enters username, display name, optional email, and password
POST /api/v1/setup/initializecreates the admin user with Argon2id-hashed password and setsauth.setup_completed = true- The setup guard cache is invalidated and subsequent requests proceed normally
12.1.2 Local Authentication
Password hashing: Argon2id with 64 MB memory, 4 iterations, 8-way parallelism, 16-byte random salt, 32-byte hash output. Stored as $argon2id$<base64-salt>$<base64-hash>. Verification uses CryptographicOperations.FixedTimeEquals to prevent timing attacks.
Login flow:
POST /api/v1/auth/loginwith username and password- Server validates credentials, checks account active status and lockout
- On success: returns JWT access token + opaque refresh token + user profile
- On failure: increments
failed_login_count; locks account for configurable duration after threshold exceeded
JWT access tokens: HMAC-SHA256 signed, configurable lifetime (default 15 minutes). Claims: sub (user ID), role, name, email, jti.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "courier",
ValidAudience = "courier-api",
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ClockSkew = TimeSpan.FromSeconds(30),
};
});
Refresh tokens: 32 random bytes (base64-encoded), stored as SHA-256 hash in the refresh_tokens table. Configurable lifetime (default 7 days). Token rotation on every refresh — the old token is revoked and a new one issued. Revoked tokens cannot be reused.
Account lockout: After configurable failed attempts (default 5), the account is locked for a configurable duration (default 15 minutes). Successful login resets the failed counter and clears the lock.
12.1.3 SSO Authentication (Phase 2/3 — Future)
The auth_providers table and SSO fields on the users table are in place but not yet active. Planned phases:
- Phase 2 — OIDC: Azure AD, Google, Okta via
Microsoft.AspNetCore.Authentication.OpenIdConnect. After OIDC verification, the server issues the same JWT + refresh token pair as local auth. Users are auto-provisioned on first login with a configurable default role. - Phase 3 — SAML: Enterprise SAML 2.0 support via
ITfoxtec.Identity.Saml2or equivalent. SP metadata and assertion consumer endpoints.
SSO users have is_sso_user = true, sso_provider_id, and sso_subject_id set. They may have no password_hash (SSO-only) or may also have a local password (dual auth).
12.2 Authorization — Role-Based Access Control (RBAC)
Courier uses a simple three-role model. For local users, roles are assigned by administrators through the user management UI (/settings/users). For SSO users (Phase 2+), roles are either mapped from the identity provider's claims or assigned a configurable default role on auto-provisioning.
Roles:
| Role | Description |
|---|---|
Admin | Full access to all resources and settings. Can manage connections, keys, system settings, and view audit logs. Intended for platform administrators. |
Operator | Can create/edit/execute jobs, chains, monitors. Can view connections and keys but cannot create or modify them. Cannot change system settings. |
Viewer | Read-only access to all resources. Can view job executions, audit logs, dashboards. Cannot create, modify, delete, or execute anything. |
Permission matrix:
| Resource | Action | Admin | Operator | Viewer |
|---|---|---|---|---|
| Jobs | View | ✓ | ✓ | ✓ |
| Jobs | Create / Edit / Delete | ✓ | ✓ | |
| Jobs | Execute / Cancel / Pause / Resume | ✓ | ✓ | |
| Job Chains | View | ✓ | ✓ | ✓ |
| Job Chains | Create / Edit / Delete | ✓ | ✓ | |
| Job Chains | Execute | ✓ | ✓ | |
| Connections | View | ✓ | ✓ | ✓ |
| Connections | Create / Edit / Delete | ✓ | ||
| Connections | Test | ✓ | ✓ | |
| PGP Keys | View metadata | ✓ | ✓ | ✓ |
| PGP Keys | Generate / Import / Delete | ✓ | ||
| PGP Keys | Export public | ✓ | ✓ | ✓ |
| PGP Keys | Export private | ✓ | ||
| PGP Keys | Create / Revoke share link | ✓ | ||
| PGP Keys | Retire / Revoke / Activate | ✓ | ||
| SSH Keys | View metadata | ✓ | ✓ | ✓ |
| SSH Keys | Generate / Import / Delete | ✓ | ||
| SSH Keys | Export public | ✓ | ✓ | ✓ |
| SSH Keys | Create / Revoke share link | ✓ | ||
| File Monitors | View | ✓ | ✓ | ✓ |
| File Monitors | Create / Edit / Delete | ✓ | ✓ | |
| File Monitors | Activate / Pause / Disable / Acknowledge | ✓ | ✓ | |
| Tags | View | ✓ | ✓ | ✓ |
| Tags | Create / Edit / Delete / Assign | ✓ | ✓ | |
| Audit Log | View | ✓ | ✓ | ✓ |
| Users | View / Create / Edit / Delete | ✓ | ||
| Users | Reset Password | ✓ | ||
| Auth Settings | View / Update | ✓ | ||
| System Settings | View | ✓ | ✓ | ✓ |
| System Settings | Update | ✓ | ||
| Dashboard | View | ✓ | ✓ | ✓ |
Enforcement:
Roles are enforced at the controller level via ASP.NET's [Authorize] attribute with role requirements. The role claim is embedded in the JWT access token issued by the Courier auth service:
// Admin-only endpoints
[Authorize(Roles = "admin")]
[HttpPost("connections")]
public async Task<ApiResponse<ConnectionDto>> CreateConnection(CreateConnectionRequest request)
// All authenticated users (any role)
[Authorize]
[HttpGet("jobs")]
public async Task<PagedApiResponse<JobDto>> ListJobs([FromQuery] JobFilter filter)
User management endpoints (/api/v1/users) are restricted to the admin role. Settings endpoints (/api/v1/settings/auth) are also admin-only. Auth and setup endpoints use [AllowAnonymous].
Guards:
CannotDeleteSelf— admins cannot delete their own accountCannotDemoteLastAdmin— the last remaining admin cannot have their role changedDuplicateUsername— usernames must be unique
12.3 Data Protection at Rest
12.3.1 Envelope Encryption (PGP & SSH Key Material)
All cryptographic key material (PGP private keys, SSH private keys) and connection credentials (passwords, passphrases) are encrypted at rest using AES-256-GCM envelope encryption. This is described in detail in Section 7.5 (Cryptography) and Section 6.8 (SSH Key Store).
Architecture:
┌─────────────────┐
│ Azure Key Vault │
│ │
│ Master Key │◄──── RSA 2048-bit, hardware-backed, never exported
│ (KEK) │ Used only to wrap/unwrap DEKs via API calls
└────────┬─────────┘
│ wrapKey / unwrapKey (RSA-OAEP-256)
┌────▼────────────┐
│ Data Encryption │
│ Key (DEK) │◄──── Random AES-256 key, unique per entity
│ │ Stored wrapped (opaque blob) alongside ciphertext
└────────┬────────┘
│ AES-256-GCM encrypt/decrypt
┌────────▼────────────┐
│ Encrypted Data │
│ (BYTEA in Postgres) │
│ [kek_ver | wrapped_dek | iv | tag | ciphertext | algo]
└──────────────────────┘
- Master key (KEK): An RSA 2048-bit key object in Azure Key Vault (not a secret). Hardware-backed where available. Never exported — the application only calls
WrapKeyandUnwrapKeyvia the Key Vault REST API'sCryptographyClient. The plaintext KEK never exists in application memory, at startup or any other time. TheEnvelopeEncryptionService(Section 7.3.7) holds aCryptographyClientreference (a remote API client), not key material. - Data Encryption Keys (DEKs): Random AES-256 keys generated per entity via
RandomNumberGenerator. After encrypting the data, the DEK is wrapped by Key Vault and stored alongside the ciphertext. At decryption time, the wrapped DEK is sent to Key Vault for unwrapping, used locally for one operation, then zeroed from memory viaCryptographicOperations.ZeroMemory. DEKs are never cached, pooled, or stored in any field — they exist in memory only for the duration of a single encrypt/decrypt call. - Algorithm: AES-256-GCM with a unique 96-bit IV per encryption operation. The IV and authentication tag are stored with the ciphertext.
- KEK version: Each encrypted blob records which KEK version was used to wrap its DEK. This enables seamless rotation — Key Vault can unwrap using any previous version.
- No key material in process memory: The
EnvelopeEncryptionServiceis a singleton that holds no mutable state — no keys, no caches, no session data. See Section 7.3.7 for the full implementation and the explicit anti-patterns it avoids.
Startup requirement: Courier refuses to start if Azure Key Vault is unreachable. The startup health check calls KeyClient.GetKeyAsync to verify connectivity and KEK existence — this retrieves key metadata (name, version, algorithm), not the private key material. If this check fails, the application throws and enters a crash loop. This is a reachability check, not a key download.
12.3.2 Database-Level Encryption
PostgreSQL is configured with Transparent Data Encryption (TDE) at the storage level via Azure Database for PostgreSQL Flexible Server's encryption-at-rest feature. This provides defense-in-depth: even if the envelope encryption layer has a bug, the raw database files on disk are encrypted.
12.3.3 What Is Encrypted
| Data | Encryption Method | Location |
|---|---|---|
| PGP private key material | AES-256-GCM envelope encryption | pgp_keys.private_key_data |
| PGP key passphrases | AES-256-GCM envelope encryption | pgp_keys.passphrase_hash |
| SSH private key material | AES-256-GCM envelope encryption | ssh_keys.private_key_data |
| SSH key passphrases | AES-256-GCM envelope encryption | ssh_keys.passphrase_hash |
| Connection passwords | AES-256-GCM envelope encryption | connections.password_encrypted |
| Database files on disk | Azure TDE | Storage layer |
| Azure Key Vault keys | HSM-backed | Key Vault |
12.3.4 What Is NOT Encrypted (and Why)
| Data | Reason |
|---|---|
| PGP public keys | Public by design — shared with partners |
| SSH public keys | Public by design — placed in authorized_keys |
| Connection hostnames/ports | Non-sensitive configuration data |
| Job configurations | May reference entity IDs but contain no secrets directly |
| Audit log entries | Historical records — no secret material is logged |
12.4 Data Protection in Transit
12.4.1 API Layer (Client ↔ Courier)
All API traffic is served over HTTPS (TLS 1.2+). HTTP connections are rejected — not redirected — in production. The TLS certificate is managed by the deployment infrastructure (Azure App Service, Kubernetes Ingress, or reverse proxy).
HSTS: The Strict-Transport-Security header is set with a 1-year max-age and includeSubDomains:
app.UseHsts(); // Strict-Transport-Security: max-age=31536000; includeSubDomains
12.4.2 File Transfer Layer (Courier ↔ Remote Servers)
- SFTP: All traffic encrypted via SSH. Algorithm preferences are configurable per connection (Section 6.7).
- FTPS Explicit: AUTH TLS upgrade before credentials are sent. Minimum TLS version configurable (default: TLS 1.2).
- FTPS Implicit: TLS from the first byte. Same TLS version floor.
- Plain FTP: Supported for legacy compatibility only. The UI displays a prominent warning when creating a plain FTP connection. Plain FTP transmits credentials and data in cleartext.
12.4.3 Database Connections
PostgreSQL connections use SSL/TLS with certificate validation. The connection string includes SSL Mode=Require (staging/production) or SSL Mode=Prefer (development).
12.4.4 Azure Key Vault
All Key Vault operations use HTTPS. Authentication is via Managed Identity (production) or Azure CLI credentials (development).
12.5 Secrets Management
Courier manages two categories of secrets: application secrets (infrastructure credentials) and user secrets (connection passwords, key passphrases). They are handled differently.
12.5.1 Application Secrets
| Secret | Production | Development |
|---|---|---|
| Database connection string | Azure Key Vault | Local secrets file (secrets.json) |
| JWT signing secret | Azure Key Vault | appsettings.Development.json |
| Encryption KEK | Azure Key Vault | appsettings.Development.json (base64) |
| Key Vault URI | Environment variable | Environment variable |
| Managed Identity client ID | Environment variable | N/A (uses Azure CLI) |
Production: Application secrets are stored in Azure Key Vault as Secret objects and loaded at startup via the AzureKeyVaultConfigurationProvider:
builder.Configuration.AddAzureKeyVault(
new Uri(builder.Configuration["KeyVault:Uri"]!),
new DefaultAzureCredential());
This loads configuration values (strings like the database connection string) into the .NET IConfiguration system. This is completely separate from the envelope encryption KEK, which is a Key Vault Key object accessed only via CryptographyClient.WrapKeyAsync / UnwrapKeyAsync at operation time, never loaded into configuration or process memory (see Section 7.3.7).
DefaultAzureCredential resolves to Managed Identity in production (no secrets on disk) and Azure CLI or Visual Studio credentials in development.
Development: Secrets that are not in Key Vault are stored in the .NET User Secrets store (secrets.json), which is outside the project directory and never committed to source control:
dotnet user-secrets set "ConnectionStrings:CourierDb" "Host=localhost;..."
dotnet user-secrets set "AzureAd:ClientSecret" "dev-client-secret"
12.5.2 User Secrets (Connection Credentials, Key Passphrases)
These are managed by the application itself and stored encrypted in PostgreSQL via the envelope encryption scheme described in Section 12.3.1. They are never written to configuration files, environment variables, or logs.
API surface rules:
- Passwords and passphrases are accepted in POST/PUT request bodies (over HTTPS)
- They are never returned in GET responses — only boolean indicators (e.g.,
hasPassword: true) - Private key export (PGP only) is an explicit action endpoint with audit logging
- SSH private keys are never exportable via the API
12.6 API Security Hardening
12.6.1 CORS
Courier's API serves a single frontend origin. CORS is configured to allow only that origin:
builder.Services.AddCors(options =>
{
options.AddPolicy("CourierFrontend", policy =>
{
policy.WithOrigins(builder.Configuration["Frontend:Origin"]!)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
In development, the frontend origin is http://localhost:3000. In production, it is the deployed Courier frontend URL. No wildcard origins are permitted.
12.6.2 Security Headers
Applied via middleware on all responses:
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["X-XSS-Protection"] = "0"; // Disabled per OWASP; CSP preferred
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; frame-ancestors 'none'; form-action 'self'";
await next();
});
12.6.3 Request Size Limits
Maximum request body size is 50 MB, sufficient for PGP/SSH key imports and large job configurations, while preventing abuse:
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; // 50 MB
});
The key import endpoint (POST /api/v1/pgp-keys/import) accepts multipart/form-data with a single file. The file size is validated server-side before processing.
12.6.4 Input Validation & Injection Prevention
- SQL injection: Eliminated by EF Core's parameterized queries. No raw SQL is executed from user input.
- JSONB injection: Step configurations and watch targets are serialized/deserialized through strongly-typed C# objects, not raw string concatenation.
- Path traversal: File paths in job step configurations are validated against an allowlist of base directories. Paths containing
.., absolute paths outside the configured data directory, and symbolic links are rejected. - Command injection: The 7z CLI wrapper (Section 8.1.2) uses
ProcessStartInfo.ArgumentList(individual arguments, no shell interpretation), an absolute binary path (/usr/bin/7z), andUseShellExecute = false. Input filenames are validated against a safe character set and rejected if they contain shell metacharacters, path traversal sequences, or control characters. All operations are sandboxed within the job's temp directory, and extracted file paths are verified post-extraction against Zip Slip attacks. See Section 8.1.2 for the full hardening specification.
12.6.5 Anti-Forgery
Since the API is purely JSON over bearer token authentication (no cookies), CSRF attacks are not applicable. The Authorization: Bearer header cannot be automatically attached by a browser in a cross-origin request without CORS cooperation, which is locked to the single frontend origin.
Token storage: Access tokens are held in memory only (JavaScript variable). Refresh tokens are stored in localStorage. While localStorage is accessible to XSS, the refresh token alone cannot access resources — it can only be exchanged for a new access token via POST /api/v1/auth/refresh, and token rotation ensures each refresh token is single-use.
12.7 Audit & Accountability
All security-relevant operations are recorded in the unified audit log (Section 13.3.5). The audit log is append-only — entries cannot be modified or deleted via any API endpoint.
Security-critical audit events:
| Operation | Logged Details |
|---|---|
Login | User ID, IP address |
PasswordChanged | User ID |
UserCreated | User ID, username, role |
UserUpdated | User ID, changed fields |
UserDeleted | User ID, username |
UserPasswordReset | Admin user ID, target user ID |
SetupInitialized | Admin user ID |
PermissionDenied | User ID, attempted action, required role, endpoint |
PrivateKeyExported | User ID, key fingerprint, export format |
KeyGenerated | User ID, algorithm, key fingerprint |
KeyRevoked | User ID, key fingerprint, reason |
ConnectionCreated | User ID, connection name, host, protocol |
ConnectionCredentialsUpdated | User ID, connection ID (never the credential itself) |
HostKeyApproved | User ID, connection ID, fingerprint, policy |
HostKeyRejected | User ID, connection ID, presented fingerprint, expected fingerprint |
SystemSettingChanged | User ID, setting key, old value, new value |
FipsOverrideEnabled | User ID, connection ID, connection host |
FipsOverrideUsed | Connection ID, negotiated algorithms, protocol |
PublicKeyShareLinkCreated | User ID, key type, key ID, expiration |
PublicKeyShareLinkUsed | Key type, key ID, requesting IP address |
PublicKeyShareLinkRevoked | User ID, key type, key ID |
InsecureHostKeyPolicyUsed | Connection ID, remote host, accepted fingerprint |
InsecureTlsPolicyUsed | Connection ID, remote host, cert subject, policy errors |
WatcherAutoDisabled | Monitor ID, overflow count, file count, reason |
Audit log protection: The audit_log_entries table has no UPDATE or DELETE exposed via the application. The EF Core DbContext does not include a Remove or Update method for AuditLogEntry. Database-level protection can be added via a PostgreSQL trigger that rejects UPDATE and DELETE on the audit table.
12.8 Sensitive Data Handling Rules
Logging: Sensitive data is never written to application logs. Structured logging is configured with a deny-list of property names that are automatically redacted:
Log.Logger = new LoggerConfiguration()
.Destructure.ByTransforming<CreateConnectionRequest>(r =>
new { r.Name, r.Host, r.Port, Password = "[REDACTED]" })
.CreateLogger();
Specific rules:
- Connection passwords: Never logged, even at Debug level
- Key passphrases: Never logged
- Private key material: Never logged
- Bearer tokens: Never logged in full — only the last 8 characters for correlation
- Request/response bodies: Logged at Debug level for non-sensitive endpoints only. Key and connection endpoints exclude sensitive fields.
Error messages: Internal error details (stack traces, database errors) are never returned to the client. The API returns a generic message with a correlation reference (e.g., err_a1b2c3d4) that maps to the detailed error in server logs.
Temp files: Job execution temp directories (/data/courier/temp/\{executionId\}/) may contain decrypted or decompressed files. These directories are cleaned up immediately on job completion or failure (Section 5.8). Paused jobs retain their temp directory but it is only accessible to the Courier application process.
12.9 Network Security
Production deployment assumes the following network topology:
- Courier API and background services run in a private subnet (Azure VNet or Kubernetes cluster)
- The frontend is served from a CDN or static hosting with API calls routed through a reverse proxy / API gateway
- PostgreSQL is accessible only from the private subnet (no public endpoint)
- Azure Key Vault is accessed via private endpoint or service endpoint within the VNet
- Outbound SFTP/FTP connections to partner servers are allowed through network security groups with destination allowlists
No inbound connections from the internet directly to the Courier API. All external traffic flows through the reverse proxy / load balancer which terminates TLS and forwards to the backend.
12.10 FIPS 140-2 / 140-3 Compliance
When security.fips_mode_enabled = true, Courier restricts all internal cryptographic operations to FIPS-approved algorithms and attempts to run on FIPS 140-2/140-3 validated cryptographic modules where the platform provides them. This section documents what that means, what is guaranteed, and where gaps remain.
12.10.1 What "FIPS Mode" Means for Courier
FIPS 140-2 and 140-3 compliance is a property of cryptographic modules, not applications. An application achieves compliance by using only FIPS-approved algorithms through FIPS-validated modules (listed on NIST's Cryptographic Module Validation Program, CMVP). Courier cannot itself be "FIPS-validated" — it relies on the validated status of the underlying platform modules.
What Courier guarantees in FIPS mode:
- Only FIPS-approved algorithms are used for internal operations (AES, RSA 2048+, SHA-256+, ECDSA P-curves). Non-approved algorithms (SHA-1, MD5, DES, Blowfish, CAST5) are rejected at the application level.
- Azure Key Vault wrap/unwrap operations use a FIPS 140-2 Level 2 (software keys) or Level 3 (HSM-backed keys) validated module for all KEK operations.
- Algorithm restrictions are enforced programmatically regardless of whether the underlying OS module is running in FIPS mode.
What Courier does not guarantee:
- That the .NET runtime's underlying cryptographic module (CNG on Windows, OpenSSL on Linux) is running in its FIPS-validated mode. This depends on OS/container configuration (see 12.10.2). Courier detects and logs the module state at startup, but correct FIPS module configuration is an operational responsibility, not an application-level guarantee.
- That BouncyCastle (used for PGP format operations) is a FIPS-validated module. It is not, even when restricted to approved algorithms (see 12.10.2).
- That outbound SFTP/FTPS connections use only FIPS-approved algorithms. Partner interoperability may require non-FIPS algorithms (see 12.10.5).
Compliance boundary:
┌──────────────────────────────────────────────────────────────────────┐
│ COURIER FIPS BOUNDARY │
│ │
│ ┌─────────────────────────┐ Validated module? │
│ │ Azure Key Vault │ YES — CMVP #4456 (or current) │
│ │ (KEK wrap/unwrap) │ Level 2 (software) / Level 3 (HSM) │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ Validated module? │
│ │ Windows CNG │ YES — if OS FIPS policy enabled │
│ │ (AES-GCM, RSA, SHA) │ CMVP #4515 (or current per OS ver) │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ Validated module? │
│ │ OpenSSL 3.x FIPS │ YES — if FIPS provider correctly │
│ │ provider (Linux) │ installed and activated │
│ │ (AES-GCM, RSA, SHA) │ CMVP #4282 (or current per version) │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ Validated module? │
│ │ BouncyCastle │ NO — approved algorithms only, │
│ │ (PGP format ops) │ no CMVP certificate │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ Validated module? │
│ │ SSH.NET / FluentFTP │ NO — transport libraries, not │
│ │ (outbound connections) │ cryptographic modules │
│ └─────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
12.10.2 Cryptographic Module Strategy
Azure Key Vault (KEK operations): FIPS 140-2 validated. Software-protected keys use Level 2; HSM-backed keys use Level 3. This is the strongest link in Courier's FIPS chain — the KEK never leaves a validated boundary.
Windows CNG (.NET on Windows): CNG is FIPS 140-2 validated (certificate varies by Windows version). To run .NET crypto through the validated module boundary, the Windows FIPS security policy must be enabled at the OS level:
# Group Policy: Computer Configuration → Windows Settings → Security Settings →
# Local Policies → Security Options →
# "System cryptography: Use FIPS compliant algorithms"
# Or registry:
HKLM\SYSTEM\CurrentControlSet\Control\Lsp\FipsAlgorithmPolicy\Enabled = 1
When this policy is enabled, .NET's System.Security.Cryptography types route through CNG's validated boundary. When disabled, they still use CNG but not necessarily in its validated mode.
OpenSSL 3.x FIPS provider (.NET on Linux / containers): OpenSSL 3.x includes a FIPS provider module that is separately validated (CMVP #4282, version-dependent). Activating it requires more than copying an openssl.cnf — the FIPS provider must be:
- Present in the container image: The base image must include a build of OpenSSL 3.x that ships the FIPS provider module (
fips.so). Not all distro packages include it — some require installingopenssl-fips-provideror building from source. - Self-tested: The FIPS module runs a self-test on first activation, producing a checksum file. This self-test must pass for the module to operate in validated mode.
- Configured as the default provider:
openssl.cnfmust activate thefipsprovider and optionally thebaseprovider (for non-crypto operations like encoding), and setfipsas the default property query.
# /etc/ssl/openssl.cnf (simplified — actual config depends on distro)
[openssl_init]
providers = provider_sect
alg_section = algorithm_sect
[provider_sect]
fips = fips_sect
base = base_sect
[fips_sect]
activate = 1
[base_sect]
activate = 1
[algorithm_sect]
default_properties = fips=yes
Operational requirement: The Dockerfile must use a base image where the OpenSSL FIPS provider is properly installed and self-tested. This is validated at image build time (see 14.2) and at application startup (see startup checks below). If the FIPS provider is not active, Courier logs a warning but does not refuse to start — the application-level algorithm restrictions still apply, but operations are not running through a validated module boundary.
Startup detection:
// Application startup — detect and log FIPS module state
// Note: .NET does not expose a simple "is FIPS module active" API.
// We probe by checking platform indicators.
if (OperatingSystem.IsWindows())
{
// Windows: check registry for FIPS policy
using var key = Registry.LocalMachine.OpenSubKey(
@"SYSTEM\CurrentControlSet\Control\Lsp\FipsAlgorithmPolicy");
var enabled = key?.GetValue("Enabled") as int? == 1;
logger.LogInformation("Windows FIPS policy: {State}",
enabled ? "Enabled (CNG validated mode)" : "Disabled");
if (!enabled)
logger.LogWarning("Windows FIPS policy is not enabled. " +
"Crypto operations use CNG but not in validated mode. " +
"Courier still restricts to approved algorithms.");
}
else if (OperatingSystem.IsLinux())
{
// Linux: probe whether OpenSSL FIPS provider is active
try
{
// Attempt an operation that requires FIPS — if the provider
// is active with default_properties=fips=yes, this succeeds.
// A more robust check: shell out to `openssl list -providers`
// and verify "fips" appears with status "active".
var result = Process.Start(new ProcessStartInfo
{
FileName = "openssl",
Arguments = "list -providers",
RedirectStandardOutput = true,
})!;
var output = await result.StandardOutput.ReadToEndAsync();
var fipsActive = output.Contains("name: OpenSSL FIPS Provider")
&& output.Contains("status: active");
logger.LogInformation("OpenSSL FIPS provider: {State}",
fipsActive ? "Active (validated mode)" : "Not active");
if (!fipsActive)
logger.LogWarning("OpenSSL FIPS provider is not active. " +
"Crypto operations use OpenSSL but not in validated mode. " +
"Courier still restricts to approved algorithms. " +
"See Section 12.10.2 for container configuration requirements.");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not detect OpenSSL FIPS provider status.");
}
}
Envelope encryption specifics:
| Operation | Module | Validated? |
|---|---|---|
| Key wrapping (KEK) | Azure Key Vault (RSA-OAEP-256) | Yes — FIPS 140-2 Level 2/3 |
| Data encryption (DEK) | AesGcm → CNG (Windows) or OpenSSL (Linux) | Yes — if OS/container FIPS mode enabled |
| DEK generation | RandomNumberGenerator → CNG/OpenSSL CSPRNG | Yes — if OS/container FIPS mode enabled |
| IV generation | RandomNumberGenerator → CNG/OpenSSL CSPRNG | Yes — if OS/container FIPS mode enabled |
PGP operations — BouncyCastle:
The standard BouncyCastle NuGet package (Portable.BouncyCastle / BouncyCastle.Cryptography) is not FIPS-validated. The Legion of the Bouncy Castle does publish a FIPS-certified line (bc-fips for core crypto, bcpg-fips for OpenPGP) that carries CMVP validation. However:
bc-fipsandbcpg-fipsare Java libraries. The .NET equivalents (bc-fips-csharp) exist but with more limited availability and documentation.- The FIPS-certified BouncyCastle line has a different API surface and licensing model (requires a support agreement).
- Migration from standard BouncyCastle to
bc-fips-csharp+bcpg-fips-csharpis a material effort that should be evaluated based on the organization's compliance requirements.
Courier's V1 approach: Use standard BouncyCastle configured to use only FIPS-approved algorithms (AES-256, RSA 2048+, SHA-256+). This provides algorithm compliance but not module validation for PGP operations. The ICryptoProvider abstraction (Section 7) isolates BouncyCastle usage, enabling a future migration to bcpg-fips-csharp if the organization requires validated-module PGP operations.
| PGP Operation | Algorithm Restriction | BouncyCastle Config |
|---|---|---|
| Symmetric encryption | AES-128, AES-192, AES-256 only | SymmetricKeyAlgorithmTag.Aes256 — explicitly set, reject others |
| Asymmetric encryption | RSA 2048+ only | PublicKeyAlgorithmTag.RsaGeneral — reject ElGamal, DSA |
| Hashing | SHA-256, SHA-384, SHA-512 only | HashAlgorithmTag.Sha256 minimum — reject SHA-1, MD5 |
| Signing | RSA 2048+ with SHA-256+ only | Reject SHA-1 signatures |
Documented limitation: PGP operations use only FIPS-approved algorithms but do not run through a FIPS-validated cryptographic module in V1. This is an accepted risk. If validated PGP operations are required, the migration path is to bcpg-fips-csharp behind the existing ICryptoProvider abstraction.
12.10.3 FIPS-Approved Algorithms
Allowed in FIPS mode (internal operations):
| Category | Approved Algorithms |
|---|---|
| Symmetric encryption | AES-128, AES-192, AES-256 (GCM or CBC mode) |
| Asymmetric encryption | RSA 2048, 3072, 4096 |
| Key exchange | ECDH P-256, P-384, P-521 |
| Digital signatures | RSA 2048+ with SHA-256+, ECDSA P-256/P-384/P-521 |
| Hashing | SHA-256, SHA-384, SHA-512, SHA-3 |
| Key wrapping | RSA-OAEP-256 (via Azure Key Vault) |
| Random generation | CNG/OpenSSL CSPRNG (DRBG) |
Explicitly prohibited in FIPS mode (internal operations):
| Algorithm | Reason |
|---|---|
| SHA-1 | Deprecated — collision attacks demonstrated |
| MD5 | Broken — not approved since FIPS 180-4 |
| DES / 3DES | Deprecated — insufficient key length |
| RSA < 2048 | Insufficient key length per NIST SP 800-131A |
| Ed25519 / Curve25519 | Not yet in most FIPS-validated modules (see 12.10.4) |
| Blowfish / CAST5 / IDEA | Not NIST-approved |
| ElGamal | Not NIST-approved |
12.10.4 Ed25519 / Curve25519 Handling
Ed25519 and Curve25519 are modern, high-performance algorithms approved in FIPS 186-5 but not yet included in most FIPS-validated cryptographic modules (CNG, OpenSSL FIPS provider).
Courier's approach:
- The algorithm enums (
SshKeyType,PgpAlgorithm) retain Ed25519 and Curve25519 values - A runtime FIPS mode flag (
system_settings.fips_mode_enabled, default:true) gates their availability - When FIPS mode is enabled: Ed25519/Curve25519 key generation is rejected with error code
4011: Algorithm not available in FIPS mode. Import of existing Ed25519 keys is allowed (they may be needed for partner connections) but flagged with a warning in the UI and audit log - When FIPS mode is disabled: All algorithms are available without restriction
- Per-connection FIPS override: Individual connections can set
fips_override = trueto allow non-FIPS algorithms for that connection only, regardless of the global FIPS mode. This is logged as a security event
Future: When FIPS-validated modules include Ed25519, the restriction can be lifted by updating the FIPS algorithm allowlist without code changes.
12.10.5 Connection-Level FIPS Configuration
Outbound connections (SFTP, FTP, FTPS) may need to negotiate algorithms that are not FIPS-approved to interoperate with partner servers. This is handled via a per-connection fips_override flag.
Default behavior (FIPS mode enabled, no override):
- SFTP: SSH.NET configured to prefer FIPS-approved key exchange (ECDH P-256/P-384,
diffie-hellman-group14-sha256,diffie-hellman-group16-sha512), encryption (aes256-ctr,aes128-ctr,[email protected]), MAC (hmac-sha2-256,hmac-sha2-512), and host key (rsa-sha2-256,rsa-sha2-512,ecdsa-sha2-nistp256) algorithms - FTPS: TLS 1.2+ with FIPS-approved cipher suites only (AES-based, no RC4, no 3DES)
With FIPS override (fips_override = true on connection):
- All algorithms supported by SSH.NET / FluentFTP are available, including
chacha20-poly1305,curve25519-sha256, Ed25519 host keys - The connection is flagged in the UI with a "Non-FIPS" badge
- Every connection using the override generates an audit event:
FipsOverrideUsedwith connection ID, negotiated algorithms, and user who configured the override
Connection entity change: Add fips_override to the connection schema:
ALTER TABLE connections ADD COLUMN fips_override BOOLEAN NOT NULL DEFAULT FALSE;
12.10.6 FIPS Enforcement Architecture
┌───────────────────────────────────────────────────────────────────┐
│ FIPS ENFORCEMENT │
│ │
│ Global: system_settings.fips_mode_enabled = true (default) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ INTERNAL OPERATIONS (approved algorithms; validated module │ │
│ │ depends on OS/container FIPS configuration) │ │
│ │ │ │
│ │ Encryption at rest ──► .NET AesGcm → CNG/OpenSSL │ │
│ │ DEK generation ──────► .NET RandomNumberGenerator → ditto │ │
│ │ Key wrapping ────────► Azure Key Vault RSA-OAEP-256 ✓ │ │
│ │ Key generation ──────► .NET RSA/ECDsa → CNG/OpenSSL │ │
│ │ Hashing ─────────────► .NET SHA256/384/512 → CNG/OpenSSL │ │
│ │ │ │
│ │ ✓ = always validated (others) = validated if OS FIPS on│ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PGP OPERATIONS (approved algorithms only; NOT a validated │ │
│ │ module — accepted risk, path to bcpg-fips-csharp exists) │ │
│ │ │ │
│ │ BouncyCastle ──► AES-256 only, RSA 2048+, SHA-256+ │ │
│ │ Reject: CAST5, IDEA, SHA-1, ElGamal │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ OUTBOUND CONNECTIONS (FIPS default, per-connection override)│ │
│ │ │ │
│ │ fips_override = false ──► FIPS cipher suites only │ │
│ │ fips_override = true ──► All algorithms + audit event │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
12.10.7 Validation & Compliance Evidence
To support FIPS compliance assessments:
- Algorithm inventory: The
/api/v1/settingsendpoint exposesfips_mode_enabledstatus. A future admin endpoint can return the complete list of algorithms in use across all connections and keys. - Module state logging: Application startup logs record whether the underlying cryptographic module (CNG or OpenSSL FIPS provider) is running in validated mode (see 12.10.2). These logs provide auditable evidence of module state per deployment.
- Audit trail: All cryptographic operations are logged with the algorithm used (
algorithmfield in audit details). Auditors can query the audit log to verify no prohibited algorithms were used for internal operations. - FIPS override tracking: All connections with
fips_override = trueare queryable. Audit events record every use of the override with the negotiated algorithm suite. - Module documentation: This section records which cryptographic modules are used, their CMVP validation status, accepted risks (BouncyCastle standard edition), and the migration path to validated PGP operations (
bcpg-fips-csharp). - Container configuration: Dockerfiles must use a base image with the OpenSSL FIPS provider correctly installed. Validation of provider status is automated at startup (see 12.10.2), not assumed from Dockerfile alone.
12.10.8 System Settings Addition
INSERT INTO system_settings (key, value, description, updated_by) VALUES
('security.fips_mode_enabled', 'true', 'Enforce FIPS 140-2 approved algorithms for internal operations', 'system'),
('security.fips_override_require_admin', 'true', 'Require Admin role to set fips_override on connections', 'system'),
('security.public_key_share_links_enabled', 'false', 'Allow Admins to generate unauthenticated share links for public keys', 'system'),
('security.max_share_link_days', '30', 'Maximum expiration period in days for public key share links', 'system'),
('security.insecure_trust_require_admin', 'true', 'Require Admin role to set AlwaysTrust (SSH) or Insecure (TLS) on connections', 'system'),
('security.insecure_trust_allow_production', 'false', 'Allow insecure trust policies in production environments', 'system');
When fips_mode_enabled is true:
- Key generation restricts to FIPS-approved algorithms
- BouncyCastle PGP operations are restricted to approved algorithms
- Connections without
fips_overridenegotiate FIPS-only cipher suites - Algorithm enums filter UI dropdowns to show only approved options
When fips_override_require_admin is true (default), only Admin role users can toggle fips_override on a connection. This prevents Operators from accidentally weakening security posture.
12.11 Security Summary
┌──────────────────────────────────────────────────────────────────┐
│ SECURITY LAYERS │
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ FIPS: Approved algorithms enforced; validated modules where ││
│ │ platform provides them; per-connection override for partners ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Network: Private subnet, NSG allowlists, no public API ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Transport: TLS 1.2+ everywhere (API, DB, Key Vault, FTPS) ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Authentication: Entra ID JWT validation (single tenant) ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Authorization: 3-role RBAC (Admin, Operator, Viewer) ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ API Hardening: CORS, security headers, input validation ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Data at Rest: AES-256-GCM envelope encryption + Azure TDE ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Secrets: Key Vault (prod), User Secrets (dev), no env vars ││
│ └─────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Audit: Append-only log, all security events, no redaction ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
└──────────────────────────────────────────────────────────────────┘
12.12 Threat Model & Trust Boundaries
This section identifies the critical assets, likely attackers, plausible attack paths, and the mitigations Courier applies. It is not a formal STRIDE analysis but provides the "what keeps us up at night" foundation for security review.
12.12.1 Trust Boundaries
┌───────────────────────────────────────────────────────────────────┐
│ TRUST BOUNDARY 1: Organization Perimeter │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ TRUST BOUNDARY 2: Courier Internal │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │ │
│ │ │ API Host │ │ Worker │ │ PostgreSQL │ │ │
│ │ │ │ │ │ │ (encrypted at rest, │ │ │
│ │ │ │ │ │ │ private subnet) │ │ │
│ │ └────┬─────┘ └────┬─────┘ └──────────────────────┘ │ │
│ │ │ │ │ │
│ └───────┼──────────────┼──────────────────────────────────────┘ │
│ │ │ │
│ ┌───────▼──────────────▼──────────────────────────────────────┐ │
│ │ TRUST BOUNDARY 3: Azure Managed Services │ │
│ │ Azure Key Vault (FIPS validated) │ Azure Blob (archive) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────┬───────────────────────────────────────────┘
│
════════════════════╪═══════════════════════════════ (Internet)
│
┌───────────────────────▼───────────────────────────────────────────┐
│ UNTRUSTED: Partner SFTP/FTP Servers │
│ (any host key, any certificate, any file content) │
└───────────────────────────────────────────────────────────────────┘
Data crossing each boundary is subject to specific controls: authentication at boundary 1 (Entra ID), encryption at boundary 2 (AES-256-GCM envelope encryption, TLS for all connections), managed-service IAM at boundary 3, and protocol-level verification at the internet boundary (host key verification, TLS cert validation).
12.12.2 Asset Inventory
| Asset | Sensitivity | Location | Primary Protection |
|---|---|---|---|
| Connection credentials (passwords) | Critical | PostgreSQL, encrypted with AES-256-GCM + Key Vault KEK | Envelope encryption (Section 12.3), KEK rotation, Admin-only access |
| SSH private keys | Critical | PostgreSQL, encrypted same as credentials | Envelope encryption, private key export denied by default (Section 6.8) |
| PGP private keys (passphrases) | Critical | PostgreSQL, passphrase encrypted same as credentials | Envelope encryption, private key export denied by default (Section 7.3) |
| Azure Key Vault KEK | Critical | Azure Key Vault (HSM-backed in production) | Azure RBAC, private endpoint, FIPS 140-2 Level 2/3 |
| Files in transit | High | Job temp directory, partner servers | TLS/SSH encryption in transit, temp dir cleanup, per-execution sandbox |
| Files at rest (temp) | High | /data/courier/temp/\{executionId\}/ | Per-execution isolation, 7-day orphan cleanup, container-local storage |
| Audit log | High (integrity) | PostgreSQL partitioned tables | Append-only (no update/delete API), tamper detection via monthly checksums |
| User identity tokens | High | In-memory (API), never persisted | Short-lived (Entra ID default), validated on every request |
| Database connection string | High | Key Vault (prod), User Secrets (dev) | Never in env vars, never logged |
| Partner server host keys | Medium | known_hosts table | TOFU or Manual verification, mismatch detection |
| Job definitions | Medium | PostgreSQL | RBAC (Operator+ to create/edit), versioned, audit logged |
| Public keys (PGP/SSH) | Low | PostgreSQL, exportable | Authenticated export by default, optional share links (Section 7.3.5) |
12.12.3 Threat Scenarios
| # | Attacker | Target Asset | Attack Path | Impact | Mitigation | Section |
|---|---|---|---|---|---|---|
| T1 | External: Network MITM | Files in transit, credentials | Intercept SFTP/FTPS connection to partner server via DNS spoofing or ARP poisoning | Credential theft, file exfiltration, file injection | SSH host key verification (TOFU/Manual), TLS cert validation (SystemTrust/PinnedThumbprint). AlwaysTrust/Insecure policies are Admin-only, FIPS-blocked, production-blocked, and audited per use. | 6.7, 6.3.2 |
| T2 | External: Malicious archive | Worker filesystem, downstream systems | Upload a crafted archive (via partner SFTP or manual upload) containing Zip Slip paths, decompression bombs, or symlink exploits | Disk exhaustion, arbitrary file overwrite, sandbox escape | Path traversal validation, symlink rejection, decompression limits (20 GB / 10K files / 200:1 ratio), no recursive extraction, post-extraction sweep. | 8.1.8 |
| T3 | Insider: Compromised Operator | Connection credentials, private keys | Use legitimate Operator role access to export credentials or reconfigure jobs to exfiltrate files | Credential exfiltration, unauthorized data access | Operators cannot export private keys (Admin-only). Operators cannot set AlwaysTrust or Insecure trust policies (Admin-only). All credential access is audit-logged. Connection passwords are never returned in API responses. | 7.3.5, 12.2 |
| T4 | Insider: Compromised Admin | Anything | Admin has full system access. Could disable FIPS mode, enable AlwaysTrust everywhere, export keys, create insecure connections. | Full system compromise | Admin actions are the highest-audited events. FIPS override, insecure trust policies, key exports, and share link creation all generate audit events. Audit log is append-only with no delete API. Admin cannot suppress audit entries. External SIEM integration (V2) provides tamper-resistant audit storage. | 12.7 |
| T5 | External: SQL injection | Database (all data) | Inject SQL via API parameters | Full database read/write | EF Core parameterized queries exclusively. No raw SQL from user input. JSONB serialized through typed C# objects. | 12.6.4 |
| T6 | External: Command injection via 7z | Worker filesystem | Craft a filename containing shell metacharacters that gets passed to the 7z CLI | Arbitrary command execution | No shell invocation (UseShellExecute = false), ArgumentList (not string interpolation), filename sanitization, absolute binary path, sandbox isolation. | 8.1.2 |
| T7 | External: Token theft | User session, API access | Steal Entra ID access token via XSS, token leak in logs, or intercepted API call | Impersonation, unauthorized actions as victim | CORS locked to single origin, security headers (CSP, X-Frame-Options), tokens never logged (sensitive data redaction), short-lived tokens (1hr), sessionStorage only (not localStorage), TLS required. See 11.2.2 for SPA-specific auth security. | 11.2.2, 12.6.1, 12.6.2, 12.8 |
| T8 | Operational: FIPS policy bypass | Compliance posture | Admin enables fips_override on connections or disables fips_mode_enabled globally | Non-compliant algorithm negotiation in audited environment | Every FIPS override generates FipsOverrideUsed audit event with negotiated algorithms. Global FIPS toggle change is audit-logged. Non-FIPS connections display a badge in the UI. Compliance reporting query surfaces all overrides. | 12.10.5 |
| T9 | Operational: Key exfiltration | PGP/SSH private keys | Admin creates share link for public key (low risk) or exports private key (high risk) | Partner impersonation, decryption of intercepted files | Private key export denied by default. Share links are for public keys only, disabled by default, Admin-only, time-limited (max 30 days), revocable, and download-counted. All actions audit-logged. | 7.3.5 |
| T10 | External: Denial of service | API availability | Flood the API with requests or queue excessive jobs | API unresponsive, job queue backed up | Rate limiting (Section 12.6), concurrency semaphore (Section 5.8), queue depth monitoring, request size limits (10 MB body). | 12.6.3 |
| T11 | External/Insider: Process memory dump | DEKs, plaintext credentials during use | Attacker with container access dumps Worker process memory to extract in-flight secrets | Credential and key material exposure for any operations active at dump time | KEK never in process memory (only in Key Vault HSM boundary). DEKs exist only for the duration of a single encrypt/decrypt call and are zeroed via CryptographicOperations.ZeroMemory. No DEK cache, no credential cache, no pre-decrypted store. Blast radius limited to operations in-flight at the exact moment of the dump. See Section 7.3.7 anti-patterns. | 7.3.7 |
12.12.4 Accepted Risks (V1)
These are known risks accepted in V1 with documented mitigations and V2 plans:
| Risk | Severity | Justification | V2 Mitigation |
|---|---|---|---|
| BouncyCastle is not a FIPS-validated module | Medium | Algorithm compliance is enforced; only module validation is missing. Acceptable if the organization's compliance requirement is algorithm-level, not module-level. | Migrate to bcpg-fips-csharp behind ICryptoProvider. |
| Single Worker instance is a single point of failure | Medium | Crash recovery is automatic (restart + re-pick queued jobs). Running jobs are lost and must be re-triggered. Acceptable for V1 internal workload. | Quartz cluster mode + event-driven scheduling enables horizontal Worker scaling. |
| Audit log is stored in the same database as application data | Medium | A compromised database admin could theoretically delete audit entries. Append-only API prevents application-level tampering, but not DBA-level. | External SIEM integration (immutable log shipping). |
| Admin role has no separation of duties | Medium | A single Admin can both configure insecure settings and suppress the UI warnings (though not the audit entries). Acceptable for small teams. | Introduce "Security Admin" role distinct from "System Admin" with approval workflows for security-sensitive changes. |
| No intrusion detection on partner file content | Low | Files received from partners are transferred as opaque blobs. Courier does not scan for malware or data loss prevention. | Integration with Azure Defender or ClamAV for file content scanning. |