Security & Multitenancy
Qadra is a multitenant platform: every organization's data, agents, and pipelines are isolated from every other. Security is layered — authentication at the edge, authorization at the boundary, and tenant scoping enforced on every database query.
Authentication Model
Authentication is split cleanly between containers. The Node.js Gateway owns the entire JWT lifecycle; Rust Core is NATS-only and never sees a raw HTTP authorization header.
Browser (:5173)
|
v
Kong (:8000) ── validates JWT signature + expiry, injects headers
|
v
Node.js Gateway (:4000) ── creates JWTs (login/register), validates on every route
|
v NATS request-reply (claims travel in the payload)
|
Rust Core (no HTTP port) ── enforces tenant_id on every query
| Stage | Responsibility |
|---|---|
| Kong | Validates the JWT signature and expiry for Qadra-issued tokens. Injects X-User-Email, X-Tenant-Id, and Authorization headers. Routes /api/* to the Gateway, /* to the React SPA. |
| Gateway | Creates JWTs on login/register, validates them on every authenticated route, extracts claims, and forwards them inside the NATS request payload. |
| Rust Core | Trusts the claims in the NATS payload and enforces tenant_id scoping at the repository layer. Has no HTTP server and never parses auth headers. |
Why this split? JWT creation/validation is a thin, latency-insensitive layer that does not need Rust's performance. Keeping it in the Gateway lets Rust Core stay a pure NATS data layer, decoupled from any HTTP framework.
Auth operations themselves run over NATS on the tenant-less qadra.auth.> subjects (login, register, refresh, store_token, revoke_token, verify_email, etc.). These are pre-authentication context, so they carry no tenant in the subject.
JWT Claims
Two token types are issued. The short-lived access token carries identity and tenant context; the long-lived refresh token carries nothing but a subject and is stored hashed.
Access Token (15 minutes)
{
"sub": "user-uuid",
"email": "user@example.com",
"name": "Jane Analyst",
"tid": "tenant-uuid",
"tname": "Acme Capital",
"role": "admin",
"sa": true
}
| Claim | Meaning |
|---|---|
sub | User ID |
email | User email |
name | Display name |
tid | Active tenant ID |
tname | Active tenant name |
role | Per-tenant role (owner, admin, user, or synthetic super_admin on drop-in) |
sa? | Present and true only for super admins. Omitted for regular users to keep the JWT small for the 99.9% case. |
Refresh Token (7 days)
{ "sub": "user-uuid", "type": "refresh" }
The refresh token is SHA256-hashed before it is stored in the database — the raw token never lives at rest. Refresh rotation (qadra.auth.refresh + store_token + revoke_token) issues a new pair and revokes the old refresh token.
Multitenancy
Every domain object is scoped by tenant_id. Row-level isolation is enforced at the repository layer in Rust Core: there is no query path that does not filter by tenant.
#![allow(unused)] fn main() { impl Repository for PostgresRepository { async fn get_workload(&self, tenant_id: Uuid, id: Uuid) -> Result<Workload> { sqlx::query_as!(Workload, "SELECT * FROM workloads WHERE tenant_id = $1 AND id = $2", tenant_id, id ).fetch_one(&self.pool).await } } }
A cross-tenant query returns empty, not an error — Tenant B querying Tenant A's data simply finds nothing.
Users Belong to Many Tenants
Users are not bound to a single organization. The user_tenants junction table maps a user to each tenant they belong to, with a per-tenant role:
CREATE TABLE user_tenants (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user',
is_default BOOLEAN NOT NULL DEFAULT false,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tenant_id)
);
| Column | Purpose |
|---|---|
role | Per-tenant role: owner, admin, or user |
is_default | Which tenant is selected on login |
joined_at | When the membership was created |
- Email is globally unique (
idx_users_email_unique). register_useris atomic — it creates the tenant, the user, and theuser_tenantsmembership in a single transaction.- Switching tenants (
POST /auth/switch-tenant) verifies membership and mints a fresh access token with the newtid/tname.
Per-Tenant Roles
| Role | Capabilities |
|---|---|
owner | Full control of the tenant. Immutable — cannot be changed or removed. |
admin | Invite/remove members, change roles, manage templates and tenant settings. |
user | Standard member access. |
Only owner/admin may invite members, change roles, or remove members. The owner role cannot be reassigned or removed.
Super Admin
Platform-level administration is a server-wide privilege, not a per-tenant role. It is a single boolean on the users table:
ALTER TABLE users ADD COLUMN is_super_admin BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX idx_users_super_admin ON users(id) WHERE is_super_admin = true;
Super admin operations run over the tenant-less qadra.admin.> subjects. Every operation independently calls is_super_admin(user_id) before executing.
| Capability | Subject |
|---|---|
| List all tenants | qadra.admin.list_tenants |
| Create tenant + assign owner | qadra.admin.create_tenant |
| List all users | qadra.admin.list_users |
| Reset a user's password | qadra.admin.reset_password |
| Drop into any tenant | qadra.admin.switch_tenant |
| Promote to super admin | qadra.admin.promote |
| Demote a super admin | qadra.admin.demote |
| Platform stats | qadra.admin.stats |
Drop-In via Synthetic JWT
When a super admin drops into a tenant they do not belong to, the Gateway mints a synthetic JWT carrying that tenant's tid/tname and role: "super_admin". This preserves a key invariant: user_tenants only ever reflects real membership. The drop-in adds no row to user_tenants, so it never pollutes tenant member lists or requires cleanup. The synthetic super_admin role is not a real per-tenant role and clearly signals temporary elevated access.
Self-Protection
- A super admin cannot promote or demote themselves.
- The first super admin is promoted only via direct SQL (or a migration seed) — there is no self-service path to elevation.
Password Hashing
Passwords are hashed with argon2 for all new credentials. bcrypt is supported only for verifying legacy hashes.
| Scenario | Algorithm |
|---|---|
| New password (register, reset) | argon2 |
Verifying a legacy hash ($2b$ / $2a$ prefix) | bcrypt |
| Verifying any other hash | argon2 |
Detection is by hash prefix: a stored hash beginning with $2b$ or $2a$ is verified with bcrypt; everything else is verified with argon2. New passwords are always written as argon2, so legacy bcrypt hashes are phased out naturally on the next password change.
Email Verification
New users receive a verification email; the flow is token-based and fire-and-forget so it never blocks registration.
ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- Token: 32 random bytes, hex-encoded (64 chars), with a 24-hour expiry.
- Verification: a token that is valid, unused, and unexpired is consumed in a single transaction that sets
used_at = NOW()andusers.email_verified = true. - Post-registration: the verification email is sent by a detached async task (
tokio::spawn) so registration latency is unaffected. - Resend (
qadra.auth.resend_verification): invalidates existing tokens, creates a new one, and sends a fresh email.
Team Invites
Invitations are token-based with smart routing — existing users are added directly, unknown emails receive an email invite.
CREATE TABLE team_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
invited_by UUID NOT NULL REFERENCES users(id),
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- Smart invite (
qadra.auth.invite_member): if a user with that email already exists globally, they are added to the tenant directly; otherwise an invite is created and an email sent. - Token: 32 random bytes, hex-encoded, with a 7-day expiry.
- Accept (
qadra.auth.accept_invite): a valid, unaccepted, unexpired token adds the user to the tenant viauser_tenants. - Cancel (
qadra.auth.cancel_invite): owner/admin may hard-delete a pending invite. - Role checks: only
owner/admincan invite; theownerrole is immutable.
Defense in Depth
Authorization is enforced at every layer, so a bypass at one layer is caught by the next:
| Layer | What It Prevents |
|---|---|
| Kong JWT validation | Invalid or expired tokens reaching the Gateway |
| Gateway middleware | Missing or malformed authentication |
| NATS payload validation | Malformed requests reaching handlers |
Repository tenant_id filter | Cross-tenant data access |
| Database foreign keys | Orphaned or invalid references |
CI Security & Supply-Chain Posture
The CI pipeline (.github/workflows/ci.yml, plus a scheduled security.yml) gates every change on a battery of security checks before any image is published.
Static & Dependency Scanning
| Check | Tool | Scope |
|---|---|---|
| Secret scanning | Gitleaks | Scans the full git history for committed secrets (API keys, passwords, tokens). |
| Dependency review | actions/dependency-review | Blocks PRs that introduce HIGH+ vulnerabilities or GPL-licensed dependencies (PR only). |
| Dockerfile linting | Hadolint | Per-image Dockerfile linting (gateway, agent, root). |
| Rust audit | cargo audit + cargo deny | Scheduled (Mondays 06:00 UTC) advisory and license checks. |
Container & Image Scanning
| Check | Tool | Output |
|---|---|---|
| Image vulnerability scan | Trivy | Scans built images for CRITICAL/HIGH issues; results uploaded as SARIF to the GitHub Security tab. |
Supply-Chain Integrity
For images published to GHCR on main:
| Mechanism | Tool | Purpose |
|---|---|---|
| Image signing | Cosign | Keyless signing via Sigstore/Fulcio — verifiable provenance of who built the image. |
| SBOM generation | Syft (SPDX JSON) | A software bill of materials per image, retained 90 days. |
| SBOM attestation | actions/attest-sbom | Attaches a verifiable provenance chain to the image. |
| Build provenance | BuildKit SBOM + Provenance | Attached to image manifests via docker/build-push-action. |
All of these jobs feed the aggregate ci-pass gate, which is the single required status check for branch protection. A failure in any security job blocks the merge.
Known Tech Debt
A handful of security shortcuts are tracked deliberately (see tech-debt-index.md):
- NATS transport is plaintext (TD-006): login/register passwords cross NATS in the clear. Acceptable because NATS is not exposed externally; NATS TLS is planned for production.
- Internal services use HTTP (TD-003): Kong terminates external TLS; mTLS between internal services is a production task.
- Credentials in
docker-compose.yml(TD-004): local-dev only; production will use Docker secrets or Vault. users.tenant_idlegacy column (TD-007): retained alongsideuser_tenantsfor backward compatibility; to be dropped once all code reads the junction table.