Identity and authorization

Doso uses one authorization stack for every product instance:

  • Session/JWT: Better Auth is canonical for human authentication, organizations, teams, invitations, JWT issuance, and JWKS publication. The web app mounts Better Auth at /api/auth. Browser API calls obtain a Better Auth JWT from /api/auth/token and forward it to the Rust API as Authorization: Bearer .... Local compose and hosted deploys both use this.
  • Local override: crate tests and the unadvertised Desktop embedded fallback may set DOSO_AUTH_MODE=local and use the loopback principal. That is not how you run compose or a hosted instance. Service-account bearer tokens (dsa_…) still work in both modes.

The Rust API remains the authorization boundary for Doso resources. It verifies human JWTs through configured JWKS (DOSO_JWKS_URL, DOSO_AUTH_ISSUER, DOSO_AUTH_AUDIENCE) and maps them into the generalized identity model. Install-wide API keys are not supported; automation uses service-account bearer tokens (dsa_…).

Authorization is enforced by the Casbin-backed policy layer in doso-core. Routes are classified into generic resource kinds and actions, then evaluated against subjects in the active scope. Workflow-specific rules must stay in workflow definitions or policy data, not in the engine.

Service accounts are non-human principals for automation. They are created and credentialed through the Rust API under /v1/service-accounts; raw credential secrets are shown once at issuance and stored only as hashes server-side.

Deployment packages#

SetupPackageAuth
Composedeploy/localBetter Auth + JWKS
Clouddeploy/cloud/Better Auth + JWKS
Orgsdeploy/saas/ overlayBetter Auth orgs + JWKS + tenant-runtime
Desktopsurface via Connect to serverAuth of the instance it opens

Canonical matrix: docs/product/deployment.md. Better Auth is required for every HTTPS / server deploy, not only SaaS.

Hosted tenant runtime#

Hosted Doso deployments use crates/doso-tenant-runtime as a separate control-plane service for platform operators. It owns tenant provisioning, suspension, soft deletion, quota metadata, placement, migrations, and tenant audit events in its own control-plane database.

Tenant creation composes doso-core by creating an organization Scope in the data-plane DATABASE_URL through IdentityStore, then seeding organization policies through AuthzEngine. Control-plane tenants mirror Better Auth organizations: external_org_id is the Better Auth organization id (the same value carried in JWT organization_id claims).

Bidirectional provisioning and edits:

  • Sign-up creates a default Better Auth organization for the user; an afterCreateOrganization hook mirrors a control-plane tenant. Public sign-up is disabled when DOSO_DISABLE_PUBLIC_SIGNUP=1. New default workspaces are capped by DOSO_SIGNUP_ORG_PER_HOUR (default 20).
  • Tenant admin creates/edits tenants via /api/operator/tenants (operator bearer token): create, patch name/slug, suspend, activate, and delete keep Better Auth organizations and control-plane tenants aligned. Prefer these routes over calling tenant-runtime directly when Better Auth is in use.
  • Better Auth afterUpdateOrganization / afterDeleteOrganization hooks sync name/slug and soft-delete onto the control plane. Suspend/activate from admin also stamps organization metadata doso_status.

Admin visibility#

  • GET /api/operator/tenants — control-plane list enriched with Better Auth member_count / invitation counts.
  • GET /api/operator/tenants/{id}/overview — members, teams, invitations, role histogram / organization roles, plus object counts (no content).
  • GET /v1/tenants/{id}/usage (tenant-runtime) — data-plane COUNT(*) by organization_scope_id, with a by_user breakdown keyed by created_by_subject (user:{betterAuthUserId}).

Data-plane creates stamp scope_id and created_by_subject from the request principal (documents, conversations, workflow runs/defs, connections). Rows created before that stamping remain on the local default scope and will not appear in org usage until rewritten.

Direct POST /v1/tenants on tenant-runtime still works for control-plane-only bootstrap; pass external_org_id when linking an existing IdP org, otherwise it defaults to the tenant id. Create is idempotent on external_org_id. Tenant lifecycle state does not branch the workflow engine; workflows continue to run against generic scopes and policies.

The data plane looks up organization scopes from a verified JWT organization_id claim and personal scopes when a JWT has no active organization. An unknown organization is not provisioned on first data-plane access and does not make the caller owner. Organization scopes and the known owner are created by control-plane events (afterCreateOrganization and operator create-tenant). Personal scopes may still be created for a human without an organization. Better Auth JWTs are issued with RS256 so the Rust JWKS verifier can validate them, and carry organization_id, roles, team_ids, and sid only when a live membership lookup succeeds.

Suspended or deleted organization scopes fail closed on every data-plane request. Personal scopes stay active.

Operator routes are protected with DOSO_TENANT_OPERATOR_TOKEN bearer auth and can be driven from the lightweight deploy/saas/tenant-admin/ frontend.

JWKS private-key encryption#

Better Auth encrypts JWKS signing private keys at rest with BETTER_AUTH_SECRET (AES-256-GCM). Hosted/JWKS mode already refuses the documented example secret.

  • Rotation: configure versioned secrets (BETTER_AUTH_SECRETS / Better Auth secrets) so existing ciphertext still decrypts while new writes use the current key.
  • Recovery: if the secret is lost or leftover plaintext keys fail to decrypt, delete the jwks table rows and restart. New encrypted key pairs are minted; existing JWTs fail verification until clients refresh.