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
StageResponsibility
KongValidates 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.
GatewayCreates JWTs on login/register, validates them on every authenticated route, extracts claims, and forwards them inside the NATS request payload.
Rust CoreTrusts 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
}
ClaimMeaning
subUser ID
emailUser email
nameDisplay name
tidActive tenant ID
tnameActive tenant name
rolePer-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)
);
ColumnPurpose
rolePer-tenant role: owner, admin, or user
is_defaultWhich tenant is selected on login
joined_atWhen the membership was created
  • Email is globally unique (idx_users_email_unique).
  • register_user is atomic — it creates the tenant, the user, and the user_tenants membership in a single transaction.
  • Switching tenants (POST /auth/switch-tenant) verifies membership and mints a fresh access token with the new tid/tname.

Per-Tenant Roles

RoleCapabilities
ownerFull control of the tenant. Immutable — cannot be changed or removed.
adminInvite/remove members, change roles, manage templates and tenant settings.
userStandard 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.

CapabilitySubject
List all tenantsqadra.admin.list_tenants
Create tenant + assign ownerqadra.admin.create_tenant
List all usersqadra.admin.list_users
Reset a user's passwordqadra.admin.reset_password
Drop into any tenantqadra.admin.switch_tenant
Promote to super adminqadra.admin.promote
Demote a super adminqadra.admin.demote
Platform statsqadra.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.

ScenarioAlgorithm
New password (register, reset)argon2
Verifying a legacy hash ($2b$ / $2a$ prefix)bcrypt
Verifying any other hashargon2

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() and users.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 via user_tenants.
  • Cancel (qadra.auth.cancel_invite): owner/admin may hard-delete a pending invite.
  • Role checks: only owner/admin can invite; the owner role is immutable.

Defense in Depth

Authorization is enforced at every layer, so a bypass at one layer is caught by the next:

LayerWhat It Prevents
Kong JWT validationInvalid or expired tokens reaching the Gateway
Gateway middlewareMissing or malformed authentication
NATS payload validationMalformed requests reaching handlers
Repository tenant_id filterCross-tenant data access
Database foreign keysOrphaned 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

CheckToolScope
Secret scanningGitleaksScans the full git history for committed secrets (API keys, passwords, tokens).
Dependency reviewactions/dependency-reviewBlocks PRs that introduce HIGH+ vulnerabilities or GPL-licensed dependencies (PR only).
Dockerfile lintingHadolintPer-image Dockerfile linting (gateway, agent, root).
Rust auditcargo audit + cargo denyScheduled (Mondays 06:00 UTC) advisory and license checks.

Container & Image Scanning

CheckToolOutput
Image vulnerability scanTrivyScans 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:

MechanismToolPurpose
Image signingCosignKeyless signing via Sigstore/Fulcio — verifiable provenance of who built the image.
SBOM generationSyft (SPDX JSON)A software bill of materials per image, retained 90 days.
SBOM attestationactions/attest-sbomAttaches a verifiable provenance chain to the image.
Build provenanceBuildKit SBOM + ProvenanceAttached 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_id legacy column (TD-007): retained alongside user_tenants for backward compatibility; to be dropped once all code reads the junction table.