Tenancy, Security, and Data Sovereignty in Saga PM
- security
- architecture
- multi-tenancy
Let’s start with the most important topic to prospective SaaS customers:
“How is my data secured?”
One tenant, one schema
Saga is multi-tenant in the simplest, hardest-to-mess-up way: every customer gets their own PostgreSQL schema. Not a shared table with a tenant_id column. A separate namespace inside the database.
saga (database)
├── public ← shared registry only (tenants, users, memberships)
├── tenant_acme ← Acme Corp — fully isolated tables
│ ├── goals
│ ├── projects
│ ├── milestones
│ ├── records
│ ├── cycles
│ └── ...
├── tenant_globex ← Globex — same shape, separate data
└── ...
The public schema holds only what has to be shared: the tenant registry, global user identities (linked to our identity service), and tenant memberships. Everything that represents your work — goals, projects, records, comments, attachments, configuration — lives inside your tenant’s schema and nowhere else.
When a request comes in, our backend does two things in order:
- Resolve which tenant the request belongs to from the session and route.
- Set PostgreSQL’s
search_pathon the database connection to that tenant’s schema before any query runs.
A query for SELECT * FROM goals against the connection literally cannot see another tenant’s goals table. There is no WHERE tenant_id = ? filter to forget, no shared row to accidentally expose. The isolation is enforced by the database, not by application code that we hope is correct on every code path.
This has operational consequences too: in production we run database connection pooling in session mode (not transaction mode) specifically so that the search_path setting doesn’t leak across tenants between queries. It’s the kind of detail that only matters if you get it wrong.
What schema-per-tenant buys you in practice
- A bug in one query can’t return another tenant’s data. A missing filter is a bug; a missing schema is impossible — the query targets the wrong namespace and either errors or returns nothing.
- Per-tenant operations are surgical. Backups, exports, migrations, and deletions can be scoped to a single schema without touching anyone else’s tables.
- Hard deletion is actually hard. Removing a tenant runs
DROP SCHEMA tenant_<slug> CASCADE— the tables and every row in them are gone, in one statement. Membership and configuration rows in the public schema cascade with them. - Phased migrations are possible. Each tenant’s schema can be on a different migration version, which lets us roll out schema changes gradually rather than as one big-bang deploy.
The tradeoff is operational complexity: thousands of schemas in one database isn’t free. We’ve planned for that — see “Where your data lives” below — but for the customers we’re talking to today, the math works out heavily in favor of stronger isolation.
Encryption: in flight, at rest, and around your secrets
The boring part is exactly the part you want to be boring.
In flight. All traffic to and from app.sagapm.io is TLS-terminated. Certificates are issued and renewed automatically by cert-manager via Let’s Encrypt; HTTP requests redirect to HTTPS at the load balancer. Internal cluster traffic between services runs inside the Kubernetes cluster network.
At rest. Our managed PostgreSQL provider encrypts disks at rest. Object storage (file attachments) is on S3-compatible storage that does the same. Backups are encrypted by the same provider and purged on a 90-day window.
Tenant secrets. A subset of data needs application-level encryption — the credentials a tenant configures for things like their own SSO provider or their own embedding API key. For these, Saga uses AES-256-GCM envelope encryption with a master key held in Kubernetes secrets, derived for separate purposes via HKDF (one key for encryption, a separate key for HMAC signing — never reused across primitives). Every encryption uses a fresh random nonce.
Two tables, with intent:
tenant_secrets— encrypted values only (e.g.oidc_client_secret, third-party API keys). Never returned to the frontend. Write-only from the UI.tenant_config— plaintext per-tenant configuration that isn’t sensitive (issuer URLs, model names, provider type).
There is no “is this sensitive?” flag to forget to check. If it’s in tenant_secrets, it’s encrypted. Period. Every secret read and write is logged at WARN level with the actor’s user ID, the tenant ID, the key, and the action — so the audit trail for credential operations is independent from the application logs.
When a tenant is deleted, the ON DELETE CASCADE on the foreign keys ensures their secrets and config rows are removed along with the tenant record. There’s no garbage to manually clean up later.
Identity is a separate problem
We don’t write our own auth. Saga delegates user identity, session management, password storage, MFA, and passkey/WebAuthn handling to Ory Kratos, the self-hosted identity service. Kratos runs inside our infrastructure on its own database (saga_kratos), separate from tenant data. Passwords are stored as cryptographic hashes; we never see the plaintext.
On top of that, we layer per-tenant policy that lives in public.tenant_auth_config:
sso_required— block password and passkey login for non-admins, force everyone through your IdPmfa_required— require a second factor (TOTP or WebAuthn) for everyone in the tenantjit_provisioning+allowed_domains— auto-create memberships for verified SSO users from your domain, so onboarding doesn’t require manual invites for every new hiresso_provider— Google Workspace, Microsoft Entra, Okta, Auth0, or a generic OIDC issuer
OIDC is per-tenant and self-service. A tenant admin enters their issuer URL, client ID, and client secret in Settings, and SSO works on the next login — no config push, no Kratos restart, no support ticket. We implement this as an OIDC relay in our own backend (Kratos still owns the session) so each tenant’s IdP is fully isolated from every other tenant’s IdP. A misconfigured issuer in one tenant cannot affect anyone else’s login flow.
For passwordless, passkeys/WebAuthn are supported alongside passwords. Users can opt in from their profile.
For audit, every admin-impactful action — granting roles, resetting MFA credentials, purging tenants — is written to an admin audit log with the actor and timestamp.
Where your data lives — and where it doesn’t
Today, Saga runs on DigitalOcean managed Kubernetes in the United States, with managed PostgreSQL and S3-compatible object storage in the same region. TLS at the edge is fronted by Cloudflare for DNS, CDN, and DDoS protection.
Our subprocessor list is intentionally short:
- DigitalOcean — compute, managed PostgreSQL, object storage
- Cloudflare — DNS, CDN, DDoS protection, web analytics (cookieless)
- Postmark — transactional email (invitations, password resets, notifications)
- Stripe — billing only (we store the customer ID and subscription ID; raw card data never touches our infrastructure)
Notably absent: third-party analytics that track users across other websites; advertising networks; data brokers. We do not sell personal information, and we do not use customer content to train machine learning models — full stop, in the privacy policy.
For customers with stricter residency or isolation requirements, the same Helm charts that deploy our SaaS deploy two other shapes:
- Single-region, dedicated tenant. Your data sits in a dedicated database in a region you specify. You’re still on our infrastructure, but not colocated with other tenants.
- Dedicated enterprise deployment. A complete Saga stack — backend, worker, frontend, Kratos, database — running in its own cluster, in a region of your choice. Subsidiaries map to tenants within that single deployment, each with their own teams, the same way our SaaS works internally. Migrating between deployment shapes is an infrastructure operation, not a code rewrite, because the schema-per-tenant model is the same in all three.
If you have a residency requirement (EU data must stay in the EU, customer data must stay in your VPC, etc.), that’s a deployment shape conversation, not a “we’ll have to redesign the product” conversation.
Getting your data deleted
When a tenant is deleted, the workflow is:
- The tenant is soft-deleted — marked as deleted in the registry, access is revoked, but the schema still exists. This protects against accidental deletion and gives a recovery window.
- After the recovery window, an admin runs the purge. The purge runs
DROP SCHEMA tenant_<slug> CASCADE, then deletes the tenant’s billing records, invites, memberships, and auth configuration in the public schema. Encrypted secrets cascade with the tenant row. - Backups containing the data are purged on a rolling 90-day window.
After step 2, the tenant’s tables are gone from the live database. After step 3, they’re gone from backups too. There is no “soft-deleted forever” state where data lingers in a table behind a flag.
Export rights — getting your data out before you delete — are covered in our follow-up post on data migration.
What we don’t yet have
A few things we’re explicit about, because the worst answer to a security question is a vague one:
- SAML is not natively supported. The three major IdPs that cover most enterprise customers (Okta, Microsoft Entra, Google Workspace) all support OIDC, which we do support. If SAML becomes a hard requirement, we plug in a SAML→OIDC bridge as a deployment-time addition; the application doesn’t change.
- SOC 2 is on the roadmap, not in hand today. We’re early, and we’d rather build the controls right than rush a report. Meanwhile we’re happy to follow up on specific control questions ([email protected]).I l
- Multi-region today is single-region. The architecture is designed to evolve to multi-region with per-region data residency (the identity layer is shared globally, tenant data stays in-region) without rewriting the application. We’re doing this when a customer needs it, not before.