OGuardAI
GuidesCase Studies

Multi-Tenant SaaS

How a SaaS platform serves three customers with different data protection policies using a single OGuardAI deployment

How a SaaS platform serves three customers with different data protection policies using a single OGuardAI deployment.


Note: Every tenant policy here tokenizes person names. Person, company, and location detection requires the Python NER sidecar (GUARDAI_DETECTOR_URL, with detector.mode: both or advanced); in builtin-only mode names are not detected and would reach the model (IBAN, SSN, and the structured IDs still are, via regex). Set detection.required_for: [person] in each policy to fail closed if the sidecar is down. Protection is scoped to detected entities: this is a data-minimization control, not an absolute guarantee. See Detector Capabilities.

The Situation

A B2B SaaS platform provides AI-powered document processing to enterprise customers. Each customer operates in a different regulatory environment:

  • Tenant A (EU retailer): GDPR strict mode. Block SSNs, mask emails, tokenize names.
  • Tenant B (US healthcare provider): HIPAA compliance. Block PHI identifiers, tokenize patient names, preserve clinical data.
  • Tenant C (Swiss bank): Financial regulations. Block IBANs from AI, full restore for internal compliance teams, masked restore for audit.

The platform needs a single OGuardAI deployment that enforces tenant-specific policies, prevents cross-tenant data access, and scales with the platform's growth.


The Solution

OGuardAI runs as a shared service in the platform's Kubernetes cluster. Each tenant has a dedicated API key, a dedicated policy configuration, and isolated session state. The sealed session encryption ensures that session blobs from one tenant cannot be used to restore another tenant's data.


Tenant Configurations

Tenant A: EU Retailer (GDPR Strict)

name: tenant-a-gdpr
version: "1.0"
rules:
  - entity_type: "person"
    protection_level: 2
    action: "tokenize"
    restore_mode: "formatted"
  - entity_type: "email"
    protection_level: 2
    action: "tokenize"
    restore_mode: "masked"
  - entity_type: "phone"
    protection_level: 2
    action: "tokenize"
    restore_mode: "masked"
  - entity_type: "address"
    protection_level: 2
    action: "tokenize"
    restore_mode: "none"
  - entity_type: "ssn"
    protection_level: 1
    action: "redact"
  - entity_type: "iban"
    protection_level: 1
    action: "tokenize"
    restore_mode: "masked"

defaults:
  protection_level: 2
  action: "tokenize"
  restore_mode: "masked"

metadata_policy:
  expose_gender: true
  expose_formality: true
  expose_language: true
  expose_role: true

channel_rules:
  customer_email:
    person: formatted
    email: masked
    phone: none
  internal_summary:
    person: full
    email: full
    phone: full

Tenant B: US Healthcare (HIPAA)

name: tenant-b-hipaa
version: "1.0"
rules:
  - entity_type: "person"
    protection_level: 2
    action: "tokenize"
    restore_mode: "full"
  - entity_type: "date_of_birth"
    protection_level: 2
    action: "tokenize"
    restore_mode: "masked"
  - entity_type: "ssn"
    protection_level: 1
    action: "redact"
  - entity_type: "insurance_id"
    protection_level: 2
    action: "tokenize"
    restore_mode: "masked"
  - entity_type: "phone"
    protection_level: 2
    action: "tokenize"
    restore_mode: "masked"
  - entity_type: "medical_record_number"
    protection_level: 1
    action: "redact"

# insurance_id and medical_record_number are tenant-defined custom types. They must be
# declared in custom_patterns before any rule references them, otherwise the policy loader
# aborts startup with "references unknown entity type".
custom_patterns:
  - entity_type: "insurance_id"
    pattern: '\bBC-\d{7}\b'
    confidence: 0.9
    context_words: ["insurance", "member", "policy"]
  - entity_type: "medical_record_number"
    pattern: '\bMRN[-:\s]?\d{6,10}\b'
    confidence: 0.9
    context_words: ["mrn", "medical record", "patient", "chart"]

defaults:
  protection_level: 2
  action: "tokenize"
  restore_mode: "masked"

metadata_policy:
  expose_gender: true
  expose_formality: true
  expose_language: true
  expose_role: true

channel_rules:
  user_output:
    person: full
    date_of_birth: full
    insurance_id: full
    phone: full
  customer_email:
    person: full
    date_of_birth: masked
    insurance_id: masked
    phone: masked

Tenant C: Swiss Bank (Financial)

name: tenant-c-financial
version: "1.0"
rules:
  - entity_type: "person"
    protection_level: 2
    action: "tokenize"
    restore_mode: "full"
  - entity_type: "iban"
    protection_level: 1
    action: "tokenize"
    restore_mode: "masked"
  - entity_type: "bic"
    protection_level: 2
    action: "tokenize"
    restore_mode: "masked"
  - entity_type: "tax_id"
    protection_level: 2
    action: "tokenize"
    restore_mode: "none"
  - entity_type: "passport"
    protection_level: 1
    action: "redact"

# bic and tax_id are tenant-defined custom types. They must be declared in
# custom_patterns before any rule or channel references them, otherwise the policy
# loader aborts startup with "references unknown entity type".
custom_patterns:
  - entity_type: "bic"
    pattern: '(?i)\b(?:bic|swift)[ \t:#.-]{0,4}([A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?)\b'
    confidence: 0.9
    value_group: 1
    context_words: ["bic", "swift"]
  - entity_type: "tax_id"
    pattern: '(?i)\b(?:tax id|tax-id|steuer-?id)[ \t:#.-]{0,4}(\d{9,11})\b'
    confidence: 0.9
    value_group: 1
    context_words: ["tax", "steuer"]

defaults:
  protection_level: 2
  action: "tokenize"
  restore_mode: "masked"

metadata_policy:
  expose_gender: true
  expose_formality: true
  expose_language: true
  expose_role: true

channel_rules:
  internal_summary:
    person: full
    iban: full
    bic: full
    tax_id: full
  log_safe:
    person: abstract
    iban: masked
    bic: masked
    tax_id: none

Some entity types above (insurance_id, medical_record_number, bic, tax_id) are custom types defined via policy rules, not built-in. See the Extending Entities guide for how to add custom types.


API Key Scoping

Each tenant's API key is bound to their policy and tenant ID:

# Tenant A request
curl -X POST http://guardai.internal:3000/v1/transform \
  -H "X-API-Key: gai_tenantA_sk_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "input": "Kunden-Email von Julia Braun (julia.braun@example.de)...",
    "policy": "tenant-a-gdpr"
  }'

# Tenant B request
curl -X POST http://guardai.internal:3000/v1/transform \
  -H "X-API-Key: gai_tenantB_sk_live_def456..." \
  -H "Content-Type: application/json" \
  -d '{
    "input": "Patient: Robert Kim, DOB: 11/15/1972, SSN: 543-21-9876...",
    "policy": "tenant-b-hipaa"
  }'

# Tenant C request
curl -X POST http://guardai.internal:3000/v1/transform \
  -H "X-API-Key: gai_tenantC_sk_live_ghi789..." \
  -H "Content-Type: application/json" \
  -d '{
    "input": "Transfer from Marcel Dubois, IBAN CH93 0076 2011 6238 5295 7...",
    "policy": "tenant-c-financial"
  }'

The server validates that the API key's tenant ID matches the requested policy. A Tenant A key cannot load Tenant B's policy.


Cross-Tenant Isolation via Sealed Sessions

Each tenant's session state is encrypted with a tenant-scoped key. The sealed blob includes the tenant ID in its authenticated data:

Session blob structure:

AES-256-GCM encrypted payload
  - token_map: {token_id -> raw_value}
  - tenant_id: "tenant_a"
  - created_at: timestamp
  - expires_at: timestamp
  AAD: tenant_id + policy_name

If Tenant B's service attempts to rehydrate using a session blob from Tenant A, the decryption fails because the authenticated additional data (AAD) includes the tenant ID. This is enforced at the cryptographic level, not just an application check.

# This fails: Tenant B key with Tenant A session blob
curl -X POST http://guardai.internal:3000/v1/rehydrate \
  -H "X-API-Key: gai_tenantB_sk_live_def456..." \
  -d '{
    "output": "Reply to `{{person:p_001:ad4f97591c16}}`...",
    "session_state": "<tenant-a-encrypted-blob>"
  }'

# Response: 403
# {"error": "session tenant binding mismatch", "code": "GUARDAI_POLICY_DENIED"}

Scaling the Deployment

The platform runs OGuardAI as a Kubernetes Deployment with horizontal pod autoscaling:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: guardai
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: guardai
          image: ghcr.io/oronts/oronts-guardai/oguardai-server:latest
          resources:
            requests:
              cpu: 500m
              memory: 256Mi
            limits:
              cpu: 2000m
              memory: 1Gi
          env:
            - name: GUARDAI_POLICY_DIR
              value: /etc/guardai/policies
            # Three replicas: revocation must be the single shared authority, or a value
            # revoked on one pod stays restorable through another.
            - name: GUARDAI_HIGH_AVAILABILITY
              value: "true"
            - name: GUARDAI_REVOCATION_BACKEND
              value: redis
            - name: GUARDAI_REDIS_URL
              value: redis://guardai-redis:6379
          volumeMounts:
            - name: policies
              mountPath: /etc/guardai/policies
      volumes:
        - name: policies
          configMap:
            name: guardai-tenant-policies

All three tenants share the same pods. Policy resolution happens per-request based on the API key's tenant binding. Session state is stateless (sealed blobs travel with the request), so any pod can handle any tenant's request. Revocation is the one piece of shared state: with more than one replica it must be the Redis authority (configured above), so a value revoked on one pod is refused by all.


What OGuardAI Made Possible

Single deployment, three regulatory regimes. GDPR, HIPAA, and Swiss financial regulations are enforced by policy configuration, not code changes. Adding a fourth tenant requires only a new policy YAML and API key.

Cryptographic tenant isolation. Sealed session encryption with tenant-scoped AAD prevents cross-tenant data access at the cryptographic level. A compromised tenant service cannot restore another tenant's PII.

Per-tenant, per-channel restore control. Each tenant defines which entity types are tokenized, blocked, or passed through, and each output channel within a tenant gets its own restore mode. The Swiss bank's compliance team sees full IBANs while their audit log sees masked versions.

Horizontal scaling. Stateless sealed sessions mean pods share no session state. Revocation is the exception: a shared Redis authority ensures a delete propagates to every pod. The platform scales OGuardAI horizontally with standard Kubernetes autoscaling, handling all tenants from the same deployment.