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
personnames. Person, company, and location detection requires the Python NER sidecar (GUARDAI_DETECTOR_URL, withdetector.mode: bothoradvanced); in builtin-only mode names are not detected and would reach the model (IBAN, SSN, and the structured IDs still are, via regex). Setdetection.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. Keep IBANs away from the model, full restore for internal compliance teams, nothing restored on the audit channel.
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"
# A channel rule can only TIGHTEN an entity's base restore_mode, never widen it, so an
# entity a channel has to restore in full carries a base of "full" and the customer-facing
# channel narrows it back down.
rules:
- entity_type: "person"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- entity_type: "email"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- entity_type: "phone"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- 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: fullTenant B: US Healthcare (HIPAA)
name: tenant-b-hipaa
version: "1.0"
# Same tighten-only rule: the clinician view (user_output) restores these in full, so the
# base is "full" and the patient-facing channel narrows it to masked.
rules:
- entity_type: "person"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- entity_type: "date_of_birth"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- entity_type: "ssn"
protection_level: 1
action: "redact"
- entity_type: "insurance_id"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- entity_type: "phone"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- 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: maskedTenant C: Swiss Bank (Financial)
name: tenant-c-financial
version: "1.0"
# Same tighten-only rule: the internal compliance view restores these in full, so the base
# is "full". tax_id keeps a base of "none", so no channel can bring it back.
rules:
- entity_type: "person"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- entity_type: "iban"
protection_level: 1
action: "tokenize"
restore_mode: "full"
- entity_type: "bic"
protection_level: 2
action: "tokenize"
restore_mode: "full"
- 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
# log_safe is deliberately absent: the runtime clamps that channel to "none", so every
# entity resolves to [REDACTED] there whatever a rule would ask for.
channel_rules:
internal_summary:
person: full
iban: full
bic: fullSome 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 Erika Mustermann (erika.mustermann@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 Max Mustermann, 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 under a key derived from the tenant id, and the tenant id, policy id, session id, expiry, and audience are all bound into the AES-256-GCM authenticated data:
Session blob structure:
AES-256-GCM encrypted payload
- token_map: {token_id -> raw_value}
- created_at: timestamp
- expires_at: timestamp
AAD: envelope version + key id + session id + tenant id + policy id
+ expiry + language + created_at + counter + audience
+ classification + key schemeThe unseal key is derived from the authenticated caller's tenant, never from the tenant id the presented blob declares. If Tenant B's service presents a blob sealed for Tenant A, the derived key is the wrong one, the AES-GCM tag fails, and no plaintext is produced. The rejection is cryptographic, not an application-level comparison, and it surfaces as an unusable session:
# 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: 410
# {"code": "GUARDAI_SESSION_EXPIRED"}With a server-held session backend (memory or redis), where the caller passes a session_id instead of a blob, the same isolation is enforced by an explicit tenant binding check on the loaded session, which returns 403 with GUARDAI_POLICY_DENIED. Both directions fail closed: an unscoped caller cannot reach a tenant-bound session, and a tenant-scoped caller cannot reach an unbound one.
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/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-policiesAll 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.
Guarded Document AI
One safe AI pipeline over four regulated documents that tokenizes each whole document, summarizes over the tokens, and restores real values only on the channel the reader is cleared for
API Reference
Complete API reference for all OGuardAI v1 endpoints including transform, rehydrate, detect, policy evaluation, health, and capabilities