-
Notifications
You must be signed in to change notification settings - Fork 2
multi_user_authentication
This document describes the multi-provider authentication system and per-user workspace isolation feature.
- Overview
- Authentication Modes
- Authentication Providers
- Per-User Workspace Isolation
- Configuration
- API Reference
- Architecture
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.
When MULTI_USER_MODE is not set or set to false:
- No login required
- All requests use a default user ID (
DEFAULT_USER_IDenv or"default-user") - Per-user folders are stored under
/_users/default-user/ - Suitable for local development and personal deployments
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}/
The system supports multiple authentication providers that can be enabled simultaneously.
| 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) |
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 adminsAfter 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:falseis 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_USERSnames them; an admin switches them on. - A disabled account is refused immediately, even with a still-valid token.
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:writeYou can also use list-based variables:
WORKFLOW_OWNER_USERS=admin
WORKFLOW_WRITE_USERS=user2
WORKFLOW_READ_USERS=user1Entries 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.
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-1Features:
- Enterprise SSO support
- User pool management via AWS Console
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=eyJxxxGoogle sign-in setup (once per deployment):
- Supabase dashboard → create a project → copy its Project URL and anon public key.
- 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.
- Supabase dashboard → Authentication → URL Configuration → Redirect URLs: add
https://<your-app-host>/auth/callback(plushttp://localhost:<port>/auth/callbackfor local dev). Set Site URL to the app origin. - 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 allowlistWhen 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:
- 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.
- That persisted directory record is sufficient admission approval. Deployment scripts do not edit
AUTH_ALLOWED_EMAILSfor individual invitations. - 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.
- Every later request reloads account and workflow permissions from the directory and workflow manifests. SSO linkage does not grant additional products or workflow access.
- Disabling the directory record blocks login and authenticated requests. Removing a person only from
AUTH_ALLOWED_EMAILSdoes 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
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=eyJxxxThe login page will display all configured providers.
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 | 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 |
-
User ID Resolution:
- Multi-user mode: User ID from JWT token claims
- Single-user mode: Default user ID from environment (
"default")
-
Path Routing (Document/File API):
- Requests to
Chats/*orDownloads/*→/_users/{userID}/... - Requests to
skills/*,Workflow/*→ root level (shared) - Implemented in
workspace/utils/path.goviaResolveUserPath()
- Requests to
-
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
- On startup,
-
Automatic Migration:
- On startup, existing
Chats/andDownloads/at root level are migrated to/_users/default/ - One-time migration for backwards compatibility
- On startup, existing
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ AuthMiddleware│ ──► │ Agent Context │ ──► │ Workspace Client│
│ (extracts ID) │ │ (user_id key) │ │ (X-User-ID hdr) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Workspace API │
│ (resolves path) │
└─────────────────┘
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.
| 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) |
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.
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:
- Bind-mounts the original workspace to a temp location
- Covers
/app/workspace-docswith tmpfs (hides everything) - Bind-mounts ReadPaths back (read-only) from the temp copy
- Bind-mounts WritePaths back (read-write) from the temp copy
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.
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
| 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 |
| 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 |
| 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) |
| Variable | Required | Description |
|---|---|---|
SUPABASE_URL |
Yes | Supabase project URL |
SUPABASE_ANON_KEY |
Yes | Supabase anonymous key |
# No authentication required
MULTI_USER_MODE=falseMULTI_USER_MODE=true
AUTH_PROVIDERS=simple
AUTH_USERS=admin:admin123
AUTH_SECRET=dev-secret-change-meMULTI_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-1GET /api/auth/modeResponse:
{
"multi_user_mode": true,
"providers": [
{"name": "simple", "type": "credentials"},
{"name": "cognito", "type": "oauth"}
]
}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"
}
}GET /api/auth/start?provider=cognitoResponse: Redirects to OAuth provider
GET /api/auth/callback?provider=cognito&code=xxx&state=xxxResponse: Exchanges code for app JWT and redirects to frontend
| 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).
| 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.
The workspace API uses the X-User-ID header for per-user folder routing:
GET /api/documents?folder=Chats
X-User-ID: user-abc123This header is automatically set by the agent API based on the authenticated user.
┌──────────┐ ┌──────────────┐
│ 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 │ │
└──────────┘ └──────────────┘
| 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
|
| 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 |
| 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 |
- Tokens expire after 24 hours
- Signed with HMAC-SHA256
- Contains: user_id, username, email, provider
- Passwords are stored as argon2id hashes in
config/users.json(64MB, 3 passes, 2 lanes) -
AUTH_USERSis 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 IDs are sanitized (alphanumeric, hyphens, underscores only)
- Maximum length: 128 characters
- Invalid IDs fall back to
"default"
- All paths validated against directory traversal attacks
- Per-user folders isolated under
/_users/{userID}/ - Users cannot access other users' files through API
- Set
MULTI_USER_MODE=true - Configure at least one auth provider
- Existing files in
Chats/andDownloads/will be migrated to/_users/default/ - Existing users can continue with the same data after migration
On first startup with this feature:
- Server checks for existing
Chats/andDownloads/at root level - If found with content, moves them to
/_users/default/ - Creates per-user folder structure
- Shared folders remain unchanged
No manual intervention required - migration is automatic and one-time.
The multi-user isolation system has comprehensive test coverage across three 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 |
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 classifiesChats/andDownloads/as per-user andskills/,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/Chatspath 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-levelChats/to_users/default/Chats/, skips already-migrated (symlinked) folders, skips empty folders, merges content in partial migration scenarios (root + user dirs both have files)
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 seessession1.jsonin theirChats/but not User2'suser2-secret.json; User2 sees their own files but not the default user's -
TestNoUserIDFallsToDefault— MissingX-User-IDheader falls back to"default"user
Cross-User Access Prevention:
-
TestDirectUsersAccessBlocked—folder=_usersrequest returns error -
TestCrossUserAccessViaUsersPath—folder=_users/default/Chatsblocked for other users (prevents path-based cross-user data access)
Shared Folders:
-
TestSharedFoldersSameForAllUsers—skills/returns identical content regardless ofX-User-ID
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 likeChats -> _users/default/Chatswould 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
# 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" -vAll commands should be run from the workspace/ directory.
Auto-synced from docs/ on main. Edit there, not here.