Skip to content

multi_user_authentication

github-actions[bot] edited this page Sep 20, 2026 · 1 revision

Multi-User Authentication & Workspace Isolation

This document describes the multi-provider authentication system and per-user workspace isolation feature.

Table of Contents

  1. Overview
  2. Authentication Modes
  3. Authentication Providers
  4. Per-User Workspace Isolation
  5. Configuration
  6. API Reference
  7. Architecture

Overview

Runloop supports two modes of operation:

Mode Description Use Case
Single-User No authentication required, uses default user ID Local development, personal use
Multi-User JWT authentication with multiple provider support Team deployment, production

In both modes, workspace files are organized per-user to ensure data isolation.


Authentication Modes

Single-User Mode (Default)

When MULTI_USER_MODE is not set or set to false:

  • No login required
  • All requests use a default user ID (DEFAULT_USER_ID env or "default-user")
  • Per-user folders are stored under /_users/default-user/
  • Suitable for local development and personal deployments

Multi-User Mode

When MULTI_USER_MODE=true:

  • JWT authentication required for all API requests
  • Multiple authentication providers supported
  • Each user gets isolated workspace folders
  • Per-user folders stored under /_users/{userID}/

Authentication Providers

The system supports multiple authentication providers that can be enabled simultaneously.

Available Providers

Provider Type Description
simple Credentials Username/password from the user directory / environment variable
cognito OAuth AWS Cognito User Pool with hosted UI
supabase Credentials Email/password verified against Supabase Auth
supabase-google OAuth Sign in with Google, hosted by Supabase Auth (PKCE)

Simple Provider

Username/password authentication against the user directory, config/users.json in the shared workspace (argon2id hashes, never plain text). See docs/design/user_accounts_and_workflow_sharing.md for the model.

Bootstrap — the directory is seeded from the environment on the first start and the env vars can then be removed:

AUTH_USERS=admin:password123,user1:secret456   # imported (hashed) into config/users.json once
ADMIN_USERS=admin                              # usernames or emails that are admins

After that, accounts are managed in the app: an admin opens Users & access (the shield button in the workflow toolbar, multi-user mode) or calls the admin API below. AUTH_USERS keeps working as a login fallback for any name not yet in the directory, so nothing breaks mid-migration.

Account record (config/users.json):

{ "users": [ { "id": "…", "username": "carol", "email": "", "password_hash": "$argon2id$…",
               "admin": false, "can_create": false, "products": ["video-studio"], "disabled": false } ] }
  • admin: manages users and product access; can open any workflow.
  • can_create: false is the read-only user — cannot create anything, sees only what is shared.
  • products: which product surfaces the account may open. Admins ignore it; a member with an empty list may open all; a read-only account with an empty list may open none.
  • SSO users (Cognito/Supabase) are created on first login with nothing enabled unless ADMIN_USERS names them; an admin switches them on.
  • A disabled account is refused immediately, even with a still-valid token.

Workflow Permissions

Workflows stay in the shared Workflow/ folder. The access tier the runtime enforces (read / write / owner, see PLAT-262 for what read may do) is derived from the user directory first: an admin is owner, an account with can_create is write, a read-only account is read. The env/file tiers below apply only to identities the directory does not know, and an unconfigured deployment keeps full owner-level access for everyone, as before. Per-workflow ownership and sharing is phase 3 of the design doc and not built yet.

Configuration:

WORKFLOW_USER_PERMISSIONS=admin:owner,user1:read,user2:write

You can also use list-based variables:

WORKFLOW_OWNER_USERS=admin
WORKFLOW_WRITE_USERS=user2
WORKFLOW_READ_USERS=user1

Entries can match the auth username, user ID, or email address. The owner-only GET /api/auth/users endpoint returns the current AUTH_USERS list with each user's workflow access.

Cognito Provider

OAuth authentication via AWS Cognito hosted UI.

Configuration:

AUTH_PROVIDERS=cognito
COGNITO_USER_POOL_ID=us-east-1_xxxxx
COGNITO_CLIENT_ID=xxxxxxxxx
COGNITO_DOMAIN=myapp.auth.us-east-1.amazoncognito.com
AWS_REGION=us-east-1

Features:

  • Enterprise SSO support
  • User pool management via AWS Console

Supabase Provider

Supabase Auth, in two flavors that can be enabled independently:

Provider name Type Login UI
supabase Credentials Email/password form, verified against Supabase Auth
supabase-google OAuth "Sign in with Google" button (Supabase-hosted Google OAuth, PKCE)

Email/password configuration:

AUTH_PROVIDERS=supabase
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=eyJxxx

Google sign-in setup (once per deployment):

  1. Supabase dashboard → create a project → copy its Project URL and anon public key.
  2. Supabase dashboard → Authentication → Providers → enable Google. It asks for a Google OAuth client:
    • Google Cloud Console → APIs & Services → Credentials → Create OAuth client ID (Web application).
    • Authorized redirect URI: https://<your-project-ref>.supabase.co/auth/v1/callback (Supabase's callback, not the app's).
    • Paste the client ID/secret back into Supabase's Google provider settings.
  3. Supabase dashboard → Authentication → URL Configuration → Redirect URLs: add https://<your-app-host>/auth/callback (plus http://localhost:<port>/auth/callback for local dev). Set Site URL to the app origin.
  4. Server env:
MULTI_USER_MODE=true
AUTH_PROVIDERS=supabase-google   # add simple and/or supabase to keep password options
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=eyJxxx
AUTH_SECRET=<long random value>
ADMIN_USERS=you@gmail.com        # your first Google login becomes admin
AUTH_ALLOWED_EMAILS=you@gmail.com,teammate@example.com  # optional exact OAuth allowlist

When AUTH_ALLOWED_EMAILS is set, OAuth sign-in accepts either an exact email from that legacy allowlist or an account that an administrator has already provisioned in config/users.json. The user directory is the durable source of truth for invitations, so adding a user in Users & access never requires a second environment-variable edit or service restart. Providers that return no email are rejected. A pre-provisioned account with a matching normalized email keeps its existing AgentWorks user ID, projects, history, and permissions; its first successful Google login links the SSO identity to that account. A disabled directory account remains rejected. Otherwise, the first Google login auto-creates an account with nothing enabled unless ADMIN_USERS names the email, and an admin then sets role/products in Users & access. Unlike Cognito, Supabase does not echo our CSRF state back — the callback falls back to its session-stored state, and the server-side PKCE verifier still binds the code to the flow that started it.

Deterministic invite and first-login contract:

  1. An administrator creates the account in Users & access with the exact email the person will use with SSO and assigns products and account permissions there.
  2. That persisted directory record is sufficient admission approval. Deployment scripts do not edit AUTH_ALLOWED_EMAILS for individual invitations.
  3. On first SSO login, the callback matches the verified provider email case-insensitively, links the provider and external ID to the existing record, and keeps the record's stable AgentWorks user ID.
  4. Every later request reloads account and workflow permissions from the directory and workflow manifests. SSO linkage does not grant additional products or workflow access.
  5. Disabling the directory record blocks login and authenticated requests. Removing a person only from AUTH_ALLOWED_EMAILS does not revoke an explicitly provisioned account; disable the account in Users & access instead.

Regression tests must prove all three admission branches: a provisioned email succeeds even when absent from AUTH_ALLOWED_EMAILS, a legacy allowlisted email succeeds, and an unknown email is denied. The existing SSO-link test also proves that first login retains the pre-provisioned user ID.

Features:

  • Social login via Supabase-hosted Google OAuth
  • Email/password authentication
  • Row-level security integration

Multiple Providers

Enable multiple providers simultaneously:

AUTH_PROVIDERS=simple,cognito,supabase
AUTH_USERS=admin:password123
COGNITO_USER_POOL_ID=us-east-1_xxxxx
COGNITO_CLIENT_ID=xxxxxxxxx
COGNITO_DOMAIN=myapp.auth.us-east-1.amazoncognito.com
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=eyJxxx

The login page will display all configured providers.


Per-User Workspace Isolation

Folder Structure

The workspace uses a hybrid folder model:

/app/workspace-docs/
├── _users/                    # Per-user isolated folders
│   ├── default/               # Fallback for single-user mode
│   │   ├── Chats/             # User's chat history
│   │   ├── Downloads/         # User's downloads
│   │   └── (plan folders live under Chats/)
│   └── user-abc123/           # Multi-user: each user gets own folder
│       ├── Chats/
│       └── Downloads/
├── Chats -> _users/default/Chats   # Symlink (for shell command access)
├── Downloads -> _users/default/Downloads
├── skills/                    # Shared across all users
└── Workflow/                  # Shared across all users

Folder Classification

Folder Type Description
Chats/ Per-User Chat session outputs, skill files, user scripts, multi-agent plan folders
Downloads/ Per-User User downloads and imports
skills/ Shared Installed skills/templates
Workflow/ Shared Workflow definitions and runs

How It Works

  1. User ID Resolution:

    • Multi-user mode: User ID from JWT token claims
    • Single-user mode: Default user ID from environment ("default")
  2. Path Routing (Document/File API):

    • Requests to Chats/* or Downloads/* → /_users/{userID}/...
    • Requests to skills/*, Workflow/* → root level (shared)
    • Implemented in workspace/utils/path.go via ResolveUserPath()
  3. Symlinks for Shell Commands:

    • On startup, EnsurePerUserSymlinks() creates root-level symlinks: Chats/ -> _users/{userID}/Chats/
    • Shell commands can use logical paths (e.g., cat Chats/file.md) and the symlink resolves to the physical per-user location
    • Symlinks are per the default user in single-user mode; multi-user deployments use the Isolator's WritePathMappings instead
  4. Automatic Migration:

    • On startup, existing Chats/ and Downloads/ at root level are migrated to /_users/default/
    • One-time migration for backwards compatibility

User ID Flow

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   AuthMiddleware│ ──► │  Agent Context  │ ──► │ Workspace Client│
│   (extracts ID) │     │  (user_id key)  │     │ (X-User-ID hdr) │
└─────────────────┘     └─────────────────┘     └─────────────────┘
                                                        │
                                                        ▼
                                                ┌─────────────────┐
                                                │  Workspace API  │
                                                │ (resolves path) │
                                                └─────────────────┘

Shell Command Isolation (FolderGuard)

Shell commands (execute_shell_command) run inside the workspace Docker container and are sandboxed using Linux mount namespaces via unshare -m. The FolderGuard system controls what the LLM can read and write.

FolderGuard Modes

Mode When Used Mechanism
Deny-list (Mode 1) Chat mode (default tools) Hides _users/ with tmpfs overlay; everything else visible
Allow-list (Mode 2) Multi-agent / workflow mode Hides entire workspace with tmpfs, then selectively bind-mounts ReadPaths (read-only) and WritePaths (read-write)

Mode 1: Deny-List (Chat Mode)

The default FolderGuard (getDefaultFolderGuard()) blocks only _users/ to prevent direct access to the internal per-user directory structure. The LLM accesses per-user folders via their logical symlinked paths (e.g., Chats/).

BlockedPaths: ["_users"]     # Hidden with tmpfs
ReadPaths:    []             # Not used (everything else is visible)
WritePaths:   []             # Not used

The agent backend additionally restricts which folders the LLM can write to via wrapExecutorsWithChatModeFolderGuard() — writes are only allowed to Chats/ (and skills/custom/ if the skill creator is active). This is enforced at the agent level before the shell command reaches the workspace API.

Mode 2: Allow-List (Multi-Agent Chat)

Multi-agent chat sub-agents use wrapExecutorsWithChatModeFolderGuard() with the standard Chats/ allow list:

ReadPaths:  ["Chats/", "Downloads/", "skills/", "subagents/", "Workflow/", "config/", "memories/"]
WritePaths: ["Chats/", "Downloads/", "config/", "memories/"]

The Isolator creates a mount namespace:

  1. Bind-mounts the original workspace to a temp location
  2. Covers /app/workspace-docs with tmpfs (hides everything)
  3. Bind-mounts ReadPaths back (read-only) from the temp copy
  4. Bind-mounts WritePaths back (read-write) from the temp copy

Per-User Write Path Mappings

For per-user folders (Chats/), the shell handler creates WritePathMappings that map logical paths to physical per-user paths:

WritePaths:        ["Chats/"]
WritePathMappings: {
  "Chats/": "_users/default/Chats/"
}

The Isolator uses these mappings to source files from _users/{userID}/Chats/ while mounting them at the logical Chats/ path. This way, shell commands use logical paths transparently, and each user's data stays isolated.

Protected Folder Enforcement

The agent backend enforces additional restrictions before shell commands reach the workspace API:

  • _users/ folder references in shell commands are blocked (prevents bypassing isolation)
  • Workflow/ folder references are blocked in chat mode (workflows have their own mode)
  • Write operations to folders outside the allowed list are rejected with an error message

Configuration

Environment Variables

Core Settings

Variable Default Description
MULTI_USER_MODE false Enable multi-user authentication
AUTH_SECRET dev default JWT signing secret (required in production)
DEFAULT_USER_ID default-user Default user ID for single-user mode

Simple Provider

Variable Required Description
AUTH_USERS Yes Comma-separated user:pass pairs
WORKFLOW_USER_PERMISSIONS No Comma-separated user:read/write/owner entries for workflow mode access
WORKFLOW_OWNER_USERS No Comma-separated users with owner workflow access
WORKFLOW_WRITE_USERS No Comma-separated users with builder/optimizer workflow access
WORKFLOW_READ_USERS No Comma-separated users limited to run mode

Cognito Provider

Variable Required Description
COGNITO_USER_POOL_ID Yes AWS Cognito User Pool ID
COGNITO_CLIENT_ID Yes Cognito App Client ID
COGNITO_DOMAIN Yes Cognito hosted UI domain
AWS_REGION Yes AWS region (e.g., us-east-1)

Supabase Provider

Variable Required Description
SUPABASE_URL Yes Supabase project URL
SUPABASE_ANON_KEY Yes Supabase anonymous key

Example Configurations

Development (Single-User)

# No authentication required
MULTI_USER_MODE=false

Development with Simple Auth

MULTI_USER_MODE=true
AUTH_PROVIDERS=simple
AUTH_USERS=admin:admin123
AUTH_SECRET=dev-secret-change-me

Production with Cognito

MULTI_USER_MODE=true
AUTH_PROVIDERS=cognito
AUTH_SECRET=your-production-secret
COGNITO_USER_POOL_ID=us-east-1_xxxxx
COGNITO_CLIENT_ID=xxxxxxxxx
COGNITO_DOMAIN=myapp.auth.us-east-1.amazoncognito.com
AWS_REGION=us-east-1

API Reference

Authentication Endpoints

Get Auth Mode

GET /api/auth/mode

Response:

{
  "multi_user_mode": true,
  "providers": [
    {"name": "simple", "type": "credentials"},
    {"name": "cognito", "type": "oauth"}
  ]
}

Login (Credentials)

POST /api/auth/login
Content-Type: application/json

{
  "username": "admin",
  "password": "password123",
  "provider": "simple"
}

Response:

{
  "token": "eyJhbGc...",
  "user": {
    "user_id": "abc123",
    "username": "admin",
    "provider": "simple"
  }
}

Start OAuth Flow

GET /api/auth/start?provider=cognito

Response: Redirects to OAuth provider

OAuth Callback

GET /api/auth/callback?provider=cognito&code=xxx&state=xxx

Response: Exchanges code for app JWT and redirects to frontend

Account management endpoints (admins only)

Method Path Purpose
GET /api/admin/users List accounts (never hashes) plus the product ids this server can host
POST /api/admin/users Create: username, optional email, password (≥8, blank = SSO only), admin, can_create, products
PUT /api/admin/users/{id} Update any of the above, password resets it, disabled switches the account off. Admins cannot demote or disable themselves
DELETE /api/admin/users/{id} Remove the record; the user's _users/<id> files are kept
POST /api/auth/password Any user: current_password, new_password

GET /api/auth/me now also returns is_admin and can_create; allowed_products is null for unrestricted accounts, an array otherwise (an empty array means no products).

Workflow sharing endpoints

Method Path Purpose
GET /api/workflow/access?workspace_path=Workflow/<folder> Owners, readers, and the caller's own level (my_access); legacy when nothing is recorded yet
PUT /api/workflow/access {workspace_path, owners:[…], readers:[…]} — ids, usernames or emails; owners/admins only; at least one owner must remain
GET /api/users/directory id, username, email of every enabled account, for the share picker

Each entry in GET /api/workflows/manifests carries my_access; workflows the caller may not see are omitted. Owners may edit, share and delete; readers get exactly the PLAT-262 read-only session (chat, run, watch, inspect) and may trigger or stop schedules but not change them.

Workspace API Headers

The workspace API uses the X-User-ID header for per-user folder routing:

GET /api/documents?folder=Chats
X-User-ID: user-abc123

This header is automatically set by the agent API based on the authenticated user.


Architecture

Authentication Flow

┌──────────┐                                    ┌──────────────┐
│ Frontend │  1. GET /api/auth/mode             │   Backend    │
│          │◄──────────────────────────────────►│              │
│          │  2. Show provider buttons          │              │
│          │                                    │              │
│          │  3a. POST /api/auth/login (simple) │              │
│          │──────────────────────────────────►│              │
│          │◄──────────────────────────────────│              │
│          │  4a. JWT token                     │              │
│          │                                    │              │
│          │  3b. GET /api/auth/start (OAuth)   │              │
│          │──────────────────────────────────►│              │
│          │  4b. Redirect to OAuth provider    │              │
│          │                                    │              │
│          │  5. OAuth callback with code       │              │
│          │◄──────────────────────────────────│              │
│          │  6. App JWT token                  │              │
└──────────┘                                    └──────────────┘

Key Files

Backend

File Description
agent_go/cmd/server/auth_middleware.go JWT validation, user context
agent_go/cmd/server/auth_providers.go Provider interface, implementations
agent_go/cmd/server/user_auth_routes.go Login, OAuth routes
agent_go/pkg/workspace/client.go Workspace client with user ID
agent_go/pkg/common/types.go Context keys including UserIDKey

Workspace API

File Description
workspace/utils/path.go Per-user path resolution, symlink setup, migration
workspace/handlers/documents.go Document handlers with user routing
workspace/handlers/shell.go Shell command handler with FolderGuard/Isolator integration
workspace/security/isolator.go Mount namespace isolation (unshare) with read/write path control
workspace/models/shell.go FolderGuardConfig struct definition
workspace/server.go Startup migration, symlink creation

Frontend

File Description
frontend/src/stores/useAuthStore.ts Auth state management
frontend/src/pages/Login.tsx Login page with providers
frontend/src/pages/AuthCallback.tsx OAuth callback handler

Security Considerations

JWT Tokens

  • Tokens expire after 24 hours
  • Signed with HMAC-SHA256
  • Contains: user_id, username, email, provider

Password Storage

  • Passwords are stored as argon2id hashes in config/users.json (64MB, 3 passes, 2 lanes)
  • AUTH_USERS is plain text in the environment and is only a bootstrap: its users are imported (hashed) on first start, after which the variable should be removed
  • Users change their own password via POST /api/auth/password; admins reset via the admin API

User ID Validation

  • User IDs are sanitized (alphanumeric, hyphens, underscores only)
  • Maximum length: 128 characters
  • Invalid IDs fall back to "default"

Path Security

  • All paths validated against directory traversal attacks
  • Per-user folders isolated under /_users/{userID}/
  • Users cannot access other users' files through API

Migration

From Single-User to Multi-User

  1. Set MULTI_USER_MODE=true
  2. Configure at least one auth provider
  3. Existing files in Chats/ and Downloads/ will be migrated to /_users/default/
  4. Existing users can continue with the same data after migration

From Legacy Workspace

On first startup with this feature:

  1. Server checks for existing Chats/ and Downloads/ at root level
  2. If found with content, moves them to /_users/default/
  3. Creates per-user folder structure
  4. Shared folders remain unchanged

No manual intervention required - migration is automatic and one-time.


Testing

The multi-user isolation system has comprehensive test coverage across three test files.

Test Files

File Tests Scope
workspace/utils/path_test.go 38 Path routing, user isolation, symlinks, migration
workspace/handlers/documents_test.go 8 Document listing API, cross-user isolation
workspace/security/isolator_test.go 6 (multi-user) FolderGuard mount scripts, sandbox profiles

Path Utilities (workspace/utils/path_test.go)

Tests for the core path routing logic that enforces per-user isolation.

User ID Validation:

  • TestIsValidUserID — Validates allowed characters (alphanumeric, hyphens, underscores), rejects special chars, path traversal attempts (../etc), and enforces max length (128 chars)
  • TestSanitizeUserID — Empty/invalid user IDs fall back to "default"

Path Routing:

  • TestIsPerUserPath — Correctly classifies Chats/ and Downloads/ as per-user and skills/, Workflow/ as shared
  • TestResolveUserPath — Per-user paths routed to _users/{userID}/, shared paths pass through unchanged, _users/ direct access blocked, invalid/empty user IDs fall back to default, full internal paths sanitized
  • TestConvertToUserRelativePath — Strips _users/{userID}/ prefix for API responses
  • TestSanitizeInputPath — Handles relative paths, full-path stripping, .. cleaning

Cross-User Security:

  • TestCrossUserIsolation — User1 cannot access User2's files; _users/user2/Chats path is blocked for User1; shared folders resolve identically for all users

Symlink Management:

  • TestEnsurePerUserSymlinks — Creates symlinks (Chats -> _users/default/Chats), idempotent on re-run, fixes wrong symlink targets, replaces empty directories with symlinks, skips non-empty directories to prevent data loss

Migration:

  • TestMigratePerUserFolders — Migrates root-level Chats/ to _users/default/Chats/, skips already-migrated (symlinked) folders, skips empty folders, merges content in partial migration scenarios (root + user dirs both have files)

Document Handler (workspace/handlers/documents_test.go)

HTTP-level tests using httptest and a real Gin router to verify the document listing API.

Root Listing Security:

  • TestRootListingFiltersUsersDirectory — _users/ directory never appears in root listing; per-user folders (Chats/, Downloads/) are injected from the user's isolated directory
  • TestRootListingWithDotFolder — folder=. parameter treated as root listing (same _users/ filtering applies)

Per-User Isolation:

  • TestPerUserFolderIsolation — Default user sees session1.json in their Chats/ but not User2's user2-secret.json; User2 sees their own files but not the default user's
  • TestNoUserIDFallsToDefault — Missing X-User-ID header falls back to "default" user

Cross-User Access Prevention:

  • TestDirectUsersAccessBlocked — folder=_users request returns error
  • TestCrossUserAccessViaUsersPath — folder=_users/default/Chats blocked for other users (prevents path-based cross-user data access)

Shared Folders:

  • TestSharedFoldersSameForAllUsers — skills/ returns identical content regardless of X-User-ID

FolderGuard / Isolator (workspace/security/isolator_test.go)

Tests for the Linux mount namespace and macOS sandbox-exec isolation scripts.

Deny-List Mode (Mode 1) Symlink Fixup:

  • TestDenyListSymlinkFixup — When _users/ is hidden with tmpfs, symlinks like Chats -> _users/default/Chats would break. Verifies that the Linux mount script preserves the workspace via bind-mount and re-mounts symlink targets after tmpfs. Verifies the macOS sandbox profile adds explicit allow rules for symlink targets within the denied path.
  • TestDenyListNoSymlinks — When no symlinks point into blocked paths, no unnecessary workspace preservation occurs (simpler script)
  • TestDenyListWithMultiUser — With multiple users (default, alice, bob), only the current user's symlink targets are exposed in the mount script. Alice and bob's directories remain hidden.

Environment Isolation:

  • TestEnvironmentIsolation — Secrets (DATABASE_URL, API_KEY) set in the parent process are NOT leaked to subprocess environment; safe PATH is present

Running Tests

# All multi-user isolation tests
go test ./utils/ ./handlers/ ./security/ -v

# Path routing tests only
go test ./utils/ -v

# Document API tests only
go test ./handlers/ -run "TestRootListing|TestPerUser|TestShared|TestDirectUsers|TestCrossUser|TestNoUser" -v

# FolderGuard/Isolator tests only
go test ./security/ -run "TestDenyList" -v

All commands should be run from the workspace/ directory.

Clone this wiki locally