DEV Community

Cover image for The SOC 2 Blueprint: Beyond RBAC with AppLevel Encryption and Audit Isolation. Part #1
Matt Mochalkin
Matt Mochalkin

Posted on

The SOC 2 Blueprint: Beyond RBAC with AppLevel Encryption and Audit Isolation. Part #1

In the modern landscape of SaaS and enterprise software, SOC 2 (System and Organization Controls 2) is no longer a “nice to have” — it is a foundational requirement for doing business. At its core, SOC 2 asks a simple but difficult question: How can you prove that only authorized users accessed specific data and how do you protect that data if your perimeter fails?

Most developers believe that standard Role-Based Access Control (RBAC) and database-level encryption-at-rest (like AWS RDS encryption) are enough. They aren’t. Standard RDS encryption protects your data if someone physically steals a hard drive from a data center but it does nothing if an attacker gains access to your running application or your primary database credentials.

In this exhaustive guide we will move beyond standard security and implement Application-Level Envelope Encryption. By the end of this article you will understand how to build a Symfony application where data is cryptographically isolated at the record level.

The Architectural Strategy - Hybrid Cryptography

Before writing a single line of code we must define our cryptographic strategy. We face a classic dilemma:

  1. Symmetric Encryption (AES): Fast, efficient for large files but requires sharing the secret key. If you use one key for everyone, a single leak compromises the entire system.
  2. Asymmetric Encryption (RSA): Great for sharing secrets securely using Public/Private key pairs, but incredibly slow for large data and has strict limits on payload size.

The Hybrid Solution - The Envelope Pattern

Envelope Encryption combines the best of both worlds.

  • Step A: We generate a unique, random, one-time-use Data Encryption Key (DEK) for every single document. We use AES-256-GCM to encrypt the document content with this DEK.
  • Step B: We take that small DEK and “wrap” (encrypt) it using the recipient’s RSA Public Key (The KEK — Key Encrypting Key).
  • Step C: We store the encrypted content and the “wrapped” DEK (the Envelope) in the database.

To decrypt, the user uses their RSA Private Key to unwrap the DEK, then uses the DEK to decrypt the document. The document itself is never encrypted with a shared password.

Deep Dive into the Primitives

Standard AES modes like CBC (Cipher Block Chaining) are vulnerable to “Bit-Flipping” attacks. An attacker might not be able to read your data, but they can modify the cipher text in a way that changes the decrypted values (e.g., changing a price from 100 to 900).

GCM (Galois/Counter Mode) is an Authenticated Encryption mode. It generates an Authentication Tag alongside the cipher text. If even a single bit of the encrypted data is modified, the decryption will fail. This provides Integrity and Authenticity, which are critical for SOC 2.

When wrapping keys, we use OAEP (Optimal Asymmetric Encryption Padding). Older padding schemes like PKCS#1 v1.5 are vulnerable to “padding oracle” attacks. OAEP adds a layer of randomness that ensures the same DEK encrypted twice with the same Public Key will result in different cipher texts.

Data Modeling for Cryptographic Isolation

We need a database schema that supports this hybrid model. We will use Symfony’s Doctrine ORM with Attribute-based mapping.

The User Entity - The Key Custodian

Each user must have an asymmetric identity. While the Private Key should be managed by a secure session vault or HSM, the Public Key is stored in our database.

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User implements UserInterface
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 180, unique: true)]
    private ?string $email = null;

    // The KEK (Key Encrypting Key) Public Component
    #[ORM\Column(type: 'text')]
    private ?string $publicKey = null;

    // ... standard getters and setters ...
}
Enter fullscreen mode Exit fullscreen mode

The Document Entity - The Encrypted Payload

The document doesn’t care who owns it, it only cares about its encrypted bits. We also store a Server-Side Envelope. This is a copy of the DEK encrypted with a Master Key. Why? Because SOC 2 requires “Availability.” If a user loses their private key, you need a way for authorized administrators to recover data or for automated backup processes to verify integrity.

#[ORM\Entity]
#[ORM\Table(name: 'documents')]
class Document
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $owner = null;

    // The AES-256-GCM Encrypted Content
    #[ORM\Column(type: 'text')]
    private ?string $encryptedContent = null;

    // The DEK encrypted with the Server's Master Key (Backup/Audit)
    #[ORM\Column(type: 'string', length: 255)]
    private ?string $serverEncryptedDek = null;
}
Enter fullscreen mode Exit fullscreen mode

The DocumentShare Entity - The Envelope Store

This is a many-to-many relationship table on steroids. It maps a document to a user and stores the DEK wrapped specifically for that user.

#[ORM\Entity]
#[ORM\Table(name: 'document_shares')]
class DocumentShare
{
    #[ORM\ManyToOne(targetEntity: Document::class, inversedBy: 'shares')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?Document $document = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?User $sharedWith = null;

    // The DEK encrypted specifically for $sharedWith's RSA Public Key
    #[ORM\Column(type: 'text')]
    private ?string $userEncryptedDek = null;
}
Enter fullscreen mode Exit fullscreen mode

Implementing the Cryptographic Engine

In Symfony, we follow the Dependency Inversion Principle. We don’t want our business logic to depend on the openssl_* functions directly. Instead, we define an interface.

The Interface - EnvelopeCryptoServiceInterface

This interface defines the contract for all cryptographic operations. If you later decide to move to AWS KMS or HashiCorp Vault, you simply implement this interface and swap the service in services.yaml.

interface EnvelopeCryptoServiceInterface {
    public function generateDek(): string;
    public function encryptContent(string $plainText, string $dek): string;
    public function decryptContent(string $payload, string $dek): ?string;
    public function encryptDekForUser(string $dek, string $publicKey): string;
    public function decryptDekWithUserKey(string $wrappedDek, string $privateKey): string;
}
Enter fullscreen mode Exit fullscreen mode

The Implementation - EnvelopeCryptoService

Using PHP’s native OpenSSL extension, we implement the logic. Note the handling of the IV (Initialization Vector) and Tag. We concatenate them with the cipher text and base64-encode the entire package for easy storage in text fields.

public function encryptContent(string $plainText, string $dek): string {
    $ivLength = openssl_cipher_iv_length('aes-256-gcm');
    $iv = random_bytes($ivLength);

    // GCM handles the $tag automatically
    $cipherText = openssl_encrypt($plainText, 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, $iv, $tag);

    // Package: [IV][TAG][CIPHERTEXT]
    return base64_encode($iv . $tag . $cipherText);
}
Enter fullscreen mode Exit fullscreen mode

The Orchestrator - DocumentManager

The DocumentManager is the most important part of our business logic. It ensures that whenever a document is created, the cryptographic chain is completed correctly and atomically.

It has three main responsibilities:

  1. Creation: Orchestrates DEK generation, content encryption and the creation of the primary owner’s envelope.
  2. Sharing: Retrieves the DEK (using the server’s backup envelope) and re-wraps it for a new recipient.
  3. Reading: Locates the user’s specific envelope and performs the multi-stage decryption.

Notice that the DocumentManager does not call flush(). It only prepares the entities. The caller (Controller or Command) is responsible for the transaction. This follows the Unit of Work pattern and ensures that we don’t end up with “half-created” records if a network error occurs.

Logical Security - Symfony Voters

Even with perfect encryption, we still need application-level logic. If Alice tries to view Bob’s document, we shouldn’t even attempt to perform the expensive RSA decryption. We use a Symfony Voter.

The DocumentVoter checks two conditions:

  1. Does the user own the document?
  2. Is there a DocumentShare record mapping the document to the user?

By placing this check in a Voter, we can use the #[IsGranted] attribute in our controllers, keeping them incredibly clean.

#[Route('/documents/{id}', name: 'document_view')]
#[IsGranted('DOCUMENT_VIEW', subject: 'document')]
public function view(Document $document, DocumentManagerInterface $manager): Response {
    // If the code execution gets here, we KNOW the user has logical access.
    // Now we just need to verify they have the cryptographic key.
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

We have successfully moved from a “Trust the Database” model to a “Trust the Math” model. Even if an intruder dumps your documents table, they see nothing but base64 gibberish. They cannot decrypt the documents without the users’ RSA Private Keys, which aren’t in the database.

However, we still have two problems:

  1. Log Leakage: If we log every change, do the logs themselves contain sensitive data?
  2. Key Centralization: If the server’s Master Key is stolen, all document DEKs can be recovered.

In Part 2, we will solve these issues by implementing Asynchronous Audit Isolation and a Sharded Key Management Service (KMS) and share full implementation GitHub repo.

Let’s Connect!

If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:

Top comments (0)