Skip to content

Latest commit

 

History

History
658 lines (481 loc) · 25.1 KB

File metadata and controls

658 lines (481 loc) · 25.1 KB

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 environment variable
cognito OAuth AWS Cognito User Pool with hosted UI
supabase OAuth Supabase Auth

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

OAuth authentication via Supabase Auth.

Configuration:

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

Features:

  • Multiple social login options
  • 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
  • TestRootListingWithDotFolderfolder=. 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:

  • TestDirectUsersAccessBlockedfolder=_users request returns error
  • TestCrossUserAccessViaUsersPathfolder=_users/default/Chats blocked for other users (prevents path-based cross-user data access)

Shared Folders:

  • TestSharedFoldersSameForAllUsersskills/ 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.